Enhance service management by adding service groups functionality; introduce new schemas for service groups and views; update API routes and handlers for service groups; implement service group creation and update logic; refactor service queries to support grouping; add new dependencies in pnpm-lock.yaml for improved UI components.
Build, Test, and Push CFDM Docker Image / test (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled

This commit is contained in:
Denozordec committed 2026-06-19 11:19:22 +07:00
1 parent 5f7b3ac388
commit 0a0a1a92f1
1321 files changed
+10817 -317

No files matched your search

+3
View File
@@ -2,6 +2,9 @@
"plugins": {
"cloudflare": {
"enabled": true
},
"claude-plugins-official/typescript-lsp": {
"enabled": true
}
}
}
+2
View File
@@ -11,6 +11,7 @@
"preview": "vite preview"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@cfdm/ui": "workspace:*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -22,6 +23,7 @@
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-vite-plugin": "^1.167.18",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
"lucide-react": "^1.18.0",
"next-themes": "^0.4.6",
"react": "^19.2.6",
@@ -0,0 +1,52 @@
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
import { Button } from '@cfdm/ui/components/button'
interface ServiceBindingIpInputProps {
id?: string
value: string[]
pool: string[]
onChange: (value: string[]) => void
disabled?: boolean
}
export function ServiceBindingIpInput({
id,
value,
pool,
onChange,
disabled,
}: ServiceBindingIpInputProps) {
const available = pool.filter((ip) => !value.includes(ip))
const isPoolEmpty = pool.length === 0
return (
<div className="flex flex-col gap-2">
<TaggedInput
id={id}
value={value}
onChange={onChange}
placeholder={
isPoolEmpty ? 'Сначала добавьте IP в пул сервиса' : 'Выберите IP из пула'
}
validate={(ip) => isValidIpv4(ip) && pool.includes(ip)}
disabled={disabled || isPoolEmpty}
/>
{available.length > 0 ? (
<div className="flex flex-wrap gap-1">
{available.map((ip) => (
<Button
key={ip}
type="button"
variant="outline"
size="xs"
disabled={disabled}
onClick={() => onChange([...value, ip])}
>
+ {ip}
</Button>
))}
</div>
) : null}
</div>
)
}
+103
View File
@@ -0,0 +1,103 @@
import { PencilIcon } from 'lucide-react'
import { StatusBadge } from '@/components/status-badge'
import { bindingToFqdn } from '@/lib/parse-fqdn'
import type { ServiceView } from '@/lib/schemas'
import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import {
Item,
ItemContent,
ItemGroup,
ItemTitle,
} from '@cfdm/ui/components/item'
interface ServiceCardProps {
service: ServiceView
onEdit: (service: ServiceView) => void
}
function aggregateSyncStatus(service: ServiceView) {
const statuses = (service.domains ?? [])
.map((domain) => domain.sync_status)
.filter((status): status is string => Boolean(status))
if (statuses.length === 0) return null
if (statuses.includes('error')) return 'error'
if (statuses.includes('pending_push')) return 'pending_push'
if (statuses.every((status) => status === 'synced')) return 'synced'
return statuses[0]
}
export function ServiceCard({ service, onEdit }: ServiceCardProps) {
const ips = service.ips ?? []
const domains = service.domains ?? []
const syncStatus = aggregateSyncStatus(service)
return (
<Card>
<CardHeader>
<CardTitle>{service.name}</CardTitle>
<CardDescription>
<Badge variant="outline">{service.slug}</Badge>
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">IP-адреса</p>
{ips.length === 0 ? (
<p className="text-sm text-muted-foreground">—</p>
) : (
<div className="flex flex-wrap gap-1.5">
{ips.map((ip) => (
<Badge key={ip} variant="secondary">
{ip}
</Badge>
))}
</div>
)}
</div>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Домены</p>
{domains.length === 0 ? (
<p className="text-sm text-muted-foreground">Не привязаны</p>
) : (
<ItemGroup>
{domains.map((binding) => (
<Item key={binding.binding_id} variant="outline" size="sm">
<ItemContent>
<ItemTitle className="flex flex-wrap items-center gap-2">
<span>{bindingToFqdn(binding)}</span>
{binding.target_ips.map((ip) => (
<Badge key={ip} variant="secondary">
{ip}
</Badge>
))}
{binding.sync_status ? (
<StatusBadge status={binding.sync_status} />
) : null}
</ItemTitle>
</ItemContent>
</Item>
))}
</ItemGroup>
)}
</div>
</CardContent>
<CardFooter className="flex flex-wrap items-center justify-between gap-2">
<div>{syncStatus ? <StatusBadge status={syncStatus} /> : null}</div>
<Button type="button" variant="outline" size="sm" onClick={() => onEdit(service)}>
<PencilIcon data-icon="inline-start" />
Редактировать
</Button>
</CardFooter>
</Card>
)
}
@@ -0,0 +1,332 @@
import { useEffect, useMemo, useState } from 'react'
import { PlusIcon, Trash2Icon } from 'lucide-react'
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
import type {
CreateServiceWithConfigInput,
DomainListItem,
ServiceGroupView,
ServiceView,
UpdateServiceConfigInput,
} from '@/lib/schemas'
import { bindingToFqdn } from '@/lib/parse-fqdn'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
Field,
FieldGroup,
FieldLabel,
} from '@cfdm/ui/components/field'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
} from '@cfdm/ui/components/item'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import { Spinner } from '@cfdm/ui/components/spinner'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
export interface ServiceBindingDraft {
fqdn: string
target_ips: string[]
}
interface ServiceEditSheetProps {
mode: 'create' | 'edit'
service: ServiceView | null
groups: ServiceGroupView[]
open: boolean
knownDomains: DomainListItem[]
isSaving: boolean
isDeleting?: boolean
onOpenChange: (open: boolean) => void
onCreate?: (body: CreateServiceWithConfigInput) => void
onSave?: (id: number, body: UpdateServiceConfigInput) => void
onDelete?: (id: number) => void
}
function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
return (service.domains ?? []).map((binding) => ({
fqdn: bindingToFqdn(binding),
target_ips: binding.target_ips ?? [],
}))
}
function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
return bindings
.filter((binding) => binding.fqdn.trim() && binding.target_ips.length > 0)
.map((binding) => ({
fqdn: binding.fqdn.trim(),
target_ips: binding.target_ips,
}))
}
export function ServiceEditSheet({
mode,
service,
groups,
open,
knownDomains,
isSaving,
isDeleting = false,
onOpenChange,
onCreate,
onSave,
onDelete,
}: ServiceEditSheetProps) {
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [serviceGroupId, setServiceGroupId] = useState('none')
const [ips, setIps] = useState<string[]>([])
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
const groupItems = useMemo(
() => [
{ label: 'Без группы', value: 'none' },
...groups.map((group) => ({ label: group.name, value: String(group.id) })),
],
[groups],
)
useEffect(() => {
if (!open) return
if (mode === 'edit' && service) {
setName(service.name)
setSlug(service.slug)
setServiceGroupId(
service.service_group_id != null ? String(service.service_group_id) : 'none',
)
setIps(service.ips ?? [])
setBindings(toBindingDrafts(service))
return
}
if (mode === 'create') {
setName('')
setSlug('')
setServiceGroupId('none')
setIps([])
setBindings([])
}
}, [open, mode, service])
const zoneHints = useMemo(
() => knownDomains.map((domain) => domain.zone_name),
[knownDomains],
)
function handleAddBinding() {
setBindings((current) => [...current, { fqdn: '', target_ips: [] }])
}
function handleRemoveBinding(index: number) {
setBindings((current) => current.filter((_, i) => i !== index))
}
function handleFqdnChange(index: number, tags: string[]) {
const fqdn = tags[0] ?? ''
setBindings((current) =>
current.map((item, i) => (i === index ? { ...item, fqdn } : item)),
)
}
function handleIpsChange(index: number, targetIps: string[]) {
setBindings((current) =>
current.map((item, i) => (i === index ? { ...item, target_ips: targetIps } : item)),
)
}
function resolveServiceGroupId(): number | null {
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
}
function handleSubmit() {
const domains = buildDomainsPayload(bindings)
const groupId = resolveServiceGroupId()
const configPayload = {
ips,
...(domains.length > 0 ? { domains } : {}),
}
if (mode === 'create') {
onCreate?.({
name: name.trim(),
slug: slug.trim(),
service_group_id: groupId,
...configPayload,
})
return
}
if (!service) return
onSave?.(service.id, {
name,
slug,
service_group_id: groupId,
...configPayload,
})
}
function handleDelete() {
if (!service) return
onDelete?.(service.id)
}
const isCreate = mode === 'create'
const canSubmit = isCreate
? name.trim().length > 0 && slug.trim().length > 0
: Boolean(service)
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="overflow-y-auto sm:max-w-lg">
<SheetHeader>
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
<SheetDescription>
Настройте IP-пул и привязки FQDN → IP. Зона определяется из FQDN автоматически.
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-4 px-4">
<FieldGroup className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
<Input
id="edit-service-name"
value={name}
placeholder={isCreate ? 'VPN Panel' : undefined}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-slug">Slug</FieldLabel>
<Input
id="edit-service-slug"
value={slug}
placeholder={isCreate ? 'vpn-panel' : undefined}
onChange={(e) => setSlug(e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
<Select
items={groupItems}
value={serviceGroupId}
onValueChange={(value) => setServiceGroupId(value ?? 'none')}
>
<SelectTrigger id="edit-service-group" className="w-full">
<SelectValue placeholder="Без группы" />
</SelectTrigger>
<SelectContent>
{groupItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
<TaggedInput
id="edit-service-ips"
value={ips}
onChange={setIps}
placeholder="192.168.1.1"
validate={isValidIpv4}
/>
</Field>
</FieldGroup>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<FieldLabel>Привязки доменов</FieldLabel>
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<PlusIcon data-icon="inline-start" />
Добавить
</Button>
</div>
{bindings.length === 0 ? (
<p className="text-sm text-muted-foreground">
Необязательно. Введите FQDN, например newdom.ivx.su — зона ivx.su определится
автоматически.
</p>
) : (
<ItemGroup>
{bindings.map((binding, index) => (
<Item key={`binding-${index}`} variant="outline">
<ItemContent className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
<TaggedInput
id={`binding-fqdn-${index}`}
value={binding.fqdn ? [binding.fqdn] : []}
onChange={(tags) => handleFqdnChange(index, tags)}
placeholder={zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'}
maxItems={1}
/>
</Field>
<Field>
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
<ServiceBindingIpInput
id={`binding-ip-${index}`}
value={binding.target_ips}
pool={ips}
onChange={(targetIps) => handleIpsChange(index, targetIps)}
/>
</Field>
</ItemContent>
<ItemActions>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Удалить привязку"
onClick={() => handleRemoveBinding(index)}
>
<Trash2Icon />
</Button>
</ItemActions>
</Item>
))}
</ItemGroup>
)}
</div>
</div>
<SheetFooter className="flex flex-row flex-wrap gap-2">
{!isCreate ? (
<Button
type="button"
variant="destructive"
disabled={!service || isDeleting || isSaving}
onClick={handleDelete}
>
{isDeleting && <Spinner data-icon="inline-start" />}
<Trash2Icon data-icon="inline-start" />
Удалить
</Button>
) : null}
<Button
type="button"
className={isCreate ? 'ml-auto' : 'ml-auto'}
disabled={!canSubmit || isSaving || isDeleting}
onClick={handleSubmit}
>
{isSaving && <Spinner data-icon="inline-start" />}
{isCreate ? 'Создать' : 'Сохранить'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,116 @@
import { ChevronDownIcon, PencilIcon } from 'lucide-react'
import { useState } from 'react'
import { ServiceGroupIcon } from '@/components/service-group-icon'
import { ServiceRow } from '@/components/service-row'
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@cfdm/ui/components/collapsible'
import { ItemGroup } from '@cfdm/ui/components/item'
import { Spinner } from '@cfdm/ui/components/spinner'
import { Switch } from '@cfdm/ui/components/switch'
import { cn } from '@cfdm/ui/lib/utils'
interface ServiceGroupCardProps {
group: ServiceGroupView
onGroupToggle: (groupId: number, enabled: boolean) => void
onServiceToggle: (serviceId: number, enabled: boolean) => void
onEditService: (service: ServiceView) => void
onEditGroup: (group: ServiceGroupView) => void
togglingGroupId?: number | null
togglingServiceId?: number | null
}
export function ServiceGroupCard({
group,
onGroupToggle,
onServiceToggle,
onEditService,
onEditGroup,
togglingGroupId = null,
togglingServiceId = null,
}: ServiceGroupCardProps) {
const [open, setOpen] = useState(true)
const isGroupToggling = togglingGroupId === group.id
return (
<Card>
<Collapsible open={open} onOpenChange={setOpen}>
<CardHeader>
<div className="flex items-center gap-2">
<CollapsibleTrigger
className={cn(
'flex flex-1 items-center gap-2 text-left',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
<ChevronDownIcon
className={cn(
'size-4 shrink-0 transition-transform',
open && 'rotate-180',
)}
/>
<ServiceGroupIcon type={group.type} />
<CardTitle className="flex-1">{group.name}</CardTitle>
</CollapsibleTrigger>
{group.domain ? (
<Badge variant="outline">{group.domain}</Badge>
) : null}
</div>
<CardAction className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={() => onEditGroup(group)}
aria-label={`Редактировать группу ${group.name}`}
>
<PencilIcon />
</Button>
{isGroupToggling ? (
<Spinner className="size-4" />
) : (
<Switch
checked={group.enabled}
disabled={isGroupToggling}
onCheckedChange={(checked) => onGroupToggle(group.id, checked)}
aria-label={`${group.enabled ? 'Выключить' : 'Включить'} группу ${group.name}`}
/>
)}
</CardAction>
</CardHeader>
<CollapsibleContent>
<CardContent>
{group.services.length === 0 ? (
<p className="text-sm text-muted-foreground">Нет сервисов в группе</p>
) : (
<ItemGroup>
{group.services.map((service) => (
<ServiceRow
key={service.id}
service={service}
onToggle={onServiceToggle}
onEdit={onEditService}
isToggling={togglingServiceId === service.id}
disabled={!group.enabled}
/>
))}
</ItemGroup>
)}
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
)
}
@@ -0,0 +1,149 @@
import { useEffect, useState } from 'react'
import type { CreateServiceGroupInput, ServiceGroup } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
Field,
FieldGroup,
FieldLabel,
} from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import { Spinner } from '@cfdm/ui/components/spinner'
const groupTypes = [
{ value: 'vpn', label: 'VPN' },
{ value: 'network', label: 'Сеть' },
{ value: 'internet', label: 'Интернет' },
{ value: 'bgp', label: 'BGP' },
{ value: 'custom', label: 'Другое' },
] as const
interface ServiceGroupEditSheetProps {
mode: 'create' | 'edit'
group: ServiceGroup | null
open: boolean
isSaving: boolean
onOpenChange: (open: boolean) => void
onCreate?: (body: CreateServiceGroupInput) => void
onSave?: (id: number, body: CreateServiceGroupInput) => void
}
export function ServiceGroupEditSheet({
mode,
group,
open,
isSaving,
onOpenChange,
onCreate,
onSave,
}: ServiceGroupEditSheetProps) {
const [name, setName] = useState('')
const [type, setType] = useState<CreateServiceGroupInput['type']>('custom')
const [domain, setDomain] = useState('')
useEffect(() => {
if (!open) return
if (mode === 'edit' && group) {
setName(group.name)
setType(group.type)
setDomain(group.domain ?? '')
} else {
setName('')
setType('custom')
setDomain('')
}
}, [open, mode, group])
function handleSubmit(event: React.FormEvent) {
event.preventDefault()
const trimmedName = name.trim()
if (!trimmedName) return
const body: CreateServiceGroupInput = {
name: trimmedName,
type,
domain: domain.trim() || null,
}
if (mode === 'create') {
onCreate?.(body)
} else if (group) {
onSave?.(group.id, body)
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>
{mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
</SheetTitle>
<SheetDescription>
Домен группы — любой FQDN (gr.ivx.su, domain.new.ivx.su). Публикуется в Cloudflare отдельно от привязок сервисов.
</SheetDescription>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-6 px-4">
<FieldGroup>
<Field>
<FieldLabel htmlFor="group-name">Название</FieldLabel>
<Input
id="group-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="VPN"
/>
</Field>
<Field>
<FieldLabel htmlFor="group-type">Тип</FieldLabel>
<Select
value={type}
onValueChange={(value) =>
setType(value as CreateServiceGroupInput['type'])
}
>
<SelectTrigger id="group-type">
<SelectValue placeholder="Выберите тип" />
</SelectTrigger>
<SelectContent>
{groupTypes.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="group-domain">Домен группы (FQDN)</FieldLabel>
<Input
id="group-domain"
value={domain}
onChange={(event) => setDomain(event.target.value)}
placeholder="domain.new.ivx.su"
/>
</Field>
</FieldGroup>
<SheetFooter>
<Button type="submit" disabled={isSaving} className="w-full">
{isSaving && <Spinner data-icon="inline-start" />}
{isSaving ? 'Сохранение…' : mode === 'create' ? 'Создать' : 'Сохранить'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,28 @@
import {
GlobeIcon,
NetworkIcon,
RouterIcon,
ServerIcon,
ShieldIcon,
type LucideIcon,
} from 'lucide-react'
import type { ServiceGroup } from '@/lib/schemas'
import { cn } from '@cfdm/ui/lib/utils'
const typeIcons: Record<ServiceGroup['type'], LucideIcon> = {
vpn: ShieldIcon,
network: NetworkIcon,
internet: GlobeIcon,
bgp: RouterIcon,
custom: ServerIcon,
}
interface ServiceGroupIconProps {
type: ServiceGroup['type']
className?: string
}
export function ServiceGroupIcon({ type, className }: ServiceGroupIconProps) {
const Icon = typeIcons[type] ?? ServerIcon
return <Icon className={cn('size-4 shrink-0', className)} />
}
@@ -0,0 +1,55 @@
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from '@cfdm/ui/components/combobox'
interface ServiceIpComboboxProps {
id?: string
value: string
items: string[]
onChange: (value: string) => void
disabled?: boolean
placeholder?: string
}
export function ServiceIpCombobox({
id,
value,
items,
onChange,
disabled,
placeholder = 'Выберите IP',
}: ServiceIpComboboxProps) {
const isDisabled = disabled || items.length === 0
return (
<Combobox
items={items}
value={value}
onValueChange={(next) => onChange(next ?? '')}
disabled={isDisabled}
>
<ComboboxInput
id={id}
className="w-full"
placeholder={items.length === 0 ? 'Сначала добавьте IP' : placeholder}
showClear={Boolean(value)}
disabled={isDisabled}
/>
<ComboboxContent>
<ComboboxEmpty>Нет совпадений</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { PencilIcon } from 'lucide-react'
import { StatusBadge } from '@/components/status-badge'
import {
aggregateServiceSyncStatus,
serviceDisplayFqdn,
} from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from '@cfdm/ui/components/item'
import { Spinner } from '@cfdm/ui/components/spinner'
import { Switch } from '@cfdm/ui/components/switch'
interface ServiceRowProps {
service: ServiceView
onToggle: (serviceId: number, enabled: boolean) => void
onEdit: (service: ServiceView) => void
isToggling?: boolean
disabled?: boolean
}
export function ServiceRow({
service,
onToggle,
onEdit,
isToggling = false,
disabled = false,
}: ServiceRowProps) {
const syncStatus = aggregateServiceSyncStatus(service)
const fqdn = serviceDisplayFqdn(service)
return (
<Item variant="outline" size="sm">
<ItemContent>
<ItemTitle>{service.name}</ItemTitle>
<ItemDescription>{fqdn}</ItemDescription>
</ItemContent>
<ItemActions className="flex flex-wrap items-center gap-2">
{syncStatus ? <StatusBadge status={syncStatus} /> : null}
{isToggling ? (
<Spinner className="size-4" />
) : (
<Switch
checked={service.enabled}
disabled={disabled || isToggling}
onCheckedChange={(checked) => onToggle(service.id, checked)}
aria-label={`${service.enabled ? 'Выключить' : 'Включить'} ${service.name}`}
/>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onEdit(service)}
>
<PencilIcon data-icon="inline-start" />
Редактировать
</Button>
</ItemActions>
</Item>
)
}
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useState } from 'react'
import { XIcon } from 'lucide-react'
import { Badge } from '@cfdm/ui/components/badge'
import {
InputGroup,
InputGroupButton,
InputGroupInput,
} from '@cfdm/ui/components/input-group'
import { cn } from '@cfdm/ui/lib/utils'
interface TaggedInputProps {
id?: string
value: string[]
onChange: (value: string[]) => void
placeholder?: string
validate?: (value: string) => boolean
maxItems?: number
disabled?: boolean
className?: string
'aria-invalid'?: boolean
}
function normalizeTag(raw: string) {
return raw.trim()
}
export function TaggedInput({
id,
value,
onChange,
placeholder,
validate,
maxItems,
disabled,
className,
'aria-invalid': ariaInvalid,
}: TaggedInputProps) {
const [pending, setPending] = useState('')
useEffect(() => {
if (!pending.includes(',')) return
const chunks = pending
.split(',')
.map(normalizeTag)
.filter(Boolean)
.filter((chunk) => !validate || validate(chunk))
if (chunks.length === 0) {
setPending('')
return
}
const next = new Set(maxItems === 1 ? chunks.slice(-1) : [...value, ...chunks])
onChange(Array.from(next))
setPending('')
}, [pending, onChange, validate, value, maxItems])
function addPending() {
const tag = normalizeTag(pending)
if (!tag) return
if (validate && !validate(tag)) return
if (value.includes(tag)) {
setPending('')
return
}
const next = maxItems === 1 ? [tag] : [...value, tag]
onChange(next)
setPending('')
}
function removeTag(tag: string) {
onChange(value.filter((item) => item !== tag))
}
return (
<InputGroup
className={cn(
'h-auto min-h-8 flex-wrap items-center gap-1.5 py-1.5',
className,
)}
>
{value.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1 pr-1">
{tag}
<InputGroupButton
type="button"
size="icon-xs"
variant="ghost"
disabled={disabled}
aria-label={`Удалить ${tag}`}
onClick={() => removeTag(tag)}
>
<XIcon />
</InputGroupButton>
</Badge>
))}
<InputGroupInput
id={id}
value={pending}
disabled={disabled}
placeholder={value.length === 0 ? placeholder : undefined}
aria-invalid={ariaInvalid}
className="min-w-24 flex-1"
onChange={(e) => setPending(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addPending()
} else if (
e.key === 'Backspace' &&
pending.length === 0 &&
value.length > 0
) {
e.preventDefault()
onChange(value.slice(0, -1))
}
}}
onBlur={addPending}
/>
</InputGroup>
)
}
export const IPV4_REGEX =
/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/
export function isValidIpv4(value: string) {
return IPV4_REGEX.test(value)
}
+53
View File
@@ -0,0 +1,53 @@
export interface ParsedFqdn {
zoneName: string
hostname: string
fqdn: string
}
export function fqdnToDisplay(hostname: string, zoneName: string): string {
if (hostname === '@') {
return zoneName
}
return `${hostname}.${zoneName}`
}
export function parseFqdn(fqdn: string, knownZones: string[]): ParsedFqdn | null {
const normalized = fqdn.trim().toLowerCase()
if (!normalized) {
return null
}
const zones = [...knownZones].sort((a, b) => b.length - a.length)
for (const zone of zones) {
const zoneLower = zone.toLowerCase()
if (normalized === zoneLower) {
return {
zoneName: zone,
hostname: '@',
fqdn: fqdnToDisplay('@', zone),
}
}
const suffix = `.${zoneLower}`
if (normalized.endsWith(suffix)) {
const prefix = normalized.slice(0, -suffix.length)
if (prefix) {
return {
zoneName: zone,
hostname: prefix,
fqdn: fqdnToDisplay(prefix, zone),
}
}
}
}
return null
}
export function bindingToFqdn(binding: {
hostname: string
zone_name: string
fqdn?: string
}): string {
return binding.fqdn ?? fqdnToDisplay(binding.hostname, binding.zone_name)
}
+110
View File
@@ -12,14 +12,74 @@ export const groupWithStatsSchema = groupSchema.extend({
domain_count: z.number(),
})
export const serviceGroupTypeSchema = z.enum([
'vpn',
'network',
'internet',
'bgp',
'custom',
])
export const serviceGroupSchema = z.object({
id: z.number(),
name: z.string(),
type: serviceGroupTypeSchema.catch('custom'),
icon: z.string().nullable(),
domain: z.string().nullable(),
enabled: z.boolean(),
created_at: z.string(),
updated_at: z.string(),
})
export const serviceSchema = z.object({
id: z.number(),
name: z.string(),
slug: z.string(),
service_group_id: z.number().nullable().optional(),
subdomain: z.string().optional(),
enabled: z.boolean().optional(),
computed_fqdn: z.string().nullable().optional(),
created_at: z.string(),
updated_at: z.string(),
})
export const serviceDomainBindingSchema = z
.object({
binding_id: z.number(),
domain_id: z.number(),
zone_name: z.string(),
hostname: z.string(),
fqdn: z.string(),
target_ips: z.array(z.string()).optional(),
target_ip: z.string().nullable().optional(),
sync_status: z.string().nullable(),
})
.transform((binding) => ({
...binding,
target_ips:
binding.target_ips && binding.target_ips.length > 0
? binding.target_ips
: binding.target_ip
? [binding.target_ip]
: [],
}))
export const serviceViewSchema = serviceSchema.extend({
subdomain: z.string().default(''),
enabled: z.boolean().default(false),
ips: z.array(z.string()).default([]),
domains: z.array(serviceDomainBindingSchema).default([]),
})
export const serviceGroupViewSchema = serviceGroupSchema.extend({
services: z.array(serviceViewSchema).default([]),
})
export const serviceGroupsResponseSchema = z.object({
groups: z.array(serviceGroupViewSchema).default([]),
ungrouped: z.array(serviceViewSchema).default([]),
})
export const domainSchema = z.object({
id: z.number(),
group_id: z.number().nullable(),
@@ -86,6 +146,11 @@ export const certificateSchema = z.object({
export type Group = z.infer<typeof groupSchema>
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
export type Service = z.infer<typeof serviceSchema>
export type ServiceDomainBinding = z.infer<typeof serviceDomainBindingSchema>
export type ServiceView = z.infer<typeof serviceViewSchema>
export type ServiceGroup = z.infer<typeof serviceGroupSchema>
export type ServiceGroupView = z.infer<typeof serviceGroupViewSchema>
export type ServiceGroupsResponse = z.infer<typeof serviceGroupsResponseSchema>
export type Domain = z.infer<typeof domainSchema>
export type DomainListItem = z.infer<typeof domainListItemSchema>
export type ServiceBinding = z.infer<typeof serviceBindingSchema>
@@ -97,11 +162,29 @@ export const createGroupSchema = z.object({
slug: z.string().min(1, 'Укажите slug'),
})
const ipv4Schema = z
.string()
.regex(
/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/,
'Некорректный IPv4',
)
const serviceDomainInputSchema = z.object({
fqdn: z.string().min(1, 'Укажите FQDN'),
target_ips: z.array(ipv4Schema).min(1, 'Выберите хотя бы один IP'),
})
export const createServiceSchema = z.object({
name: z.string().min(1, 'Укажите название'),
slug: z.string().min(1, 'Укажите slug'),
})
export const createServiceWithConfigSchema = createServiceSchema.extend({
service_group_id: z.number().nullable().optional(),
ips: z.array(ipv4Schema).default([]),
domains: z.array(serviceDomainInputSchema).default([]),
})
export const createServiceBindingSchema = z.object({
domain_id: z.string().min(1, 'Выберите домен'),
service_id: z.string().min(1, 'Выберите сервис'),
@@ -134,6 +217,33 @@ export const createDnsRecordSchema = z.object({
export type CreateGroupInput = z.infer<typeof createGroupSchema>
export type CreateServiceInput = z.infer<typeof createServiceSchema>
export type CreateServiceWithConfigInput = z.infer<typeof createServiceWithConfigSchema>
export const updateServiceConfigSchema = z.object({
name: z.string().min(1, 'Укажите название').optional(),
slug: z.string().min(1, 'Укажите slug').optional(),
service_group_id: z.number().nullable().optional(),
ips: z.array(ipv4Schema).optional(),
domains: z
.array(serviceDomainInputSchema)
.optional(),
})
export type UpdateServiceConfigInput = z.infer<typeof updateServiceConfigSchema>
export const createServiceGroupSchema = z.object({
name: z.string().min(1, 'Укажите название'),
type: serviceGroupTypeSchema.default('custom'),
icon: z.string().nullable().optional(),
domain: z.string().nullable().optional(),
})
export const toggleEnabledSchema = z.object({
enabled: z.boolean(),
})
export type CreateServiceGroupInput = z.infer<typeof createServiceGroupSchema>
export type ToggleEnabledInput = z.infer<typeof toggleEnabledSchema>
export type CreateServiceBindingInput = z.infer<typeof createServiceBindingSchema>
export type CreateDomainInput = z.infer<typeof createDomainSchema>
export type LoginInput = z.infer<typeof loginSchema>
+21
View File
@@ -0,0 +1,21 @@
import { bindingToFqdn } from '@/lib/parse-fqdn'
import type { ServiceView } from '@/lib/schemas'
export function serviceDisplayFqdn(service: ServiceView): string {
const first = service.domains?.[0]
if (first) {
return bindingToFqdn(first)
}
return '—'
}
export function aggregateServiceSyncStatus(service: ServiceView): string | null {
const statuses = (service.domains ?? [])
.map((domain) => domain.sync_status)
.filter((status): status is string => Boolean(status))
if (statuses.length === 0) return null
if (statuses.includes('error')) return 'error'
if (statuses.includes('pending_push')) return 'pending_push'
if (statuses.every((status) => status === 'synced')) return 'synced'
return statuses[0]
}
+16 -2
View File
@@ -8,7 +8,8 @@ import {
groupSchema,
groupWithStatsSchema,
serviceBindingSchema,
serviceSchema,
serviceGroupsResponseSchema,
serviceViewSchema,
} from '@/lib/schemas'
import { subdomainSchema } from '@/lib/schemas-ext'
import { z } from 'zod'
@@ -40,12 +41,25 @@ export const serviceKeys = {
all: ['services'] as const,
}
export const serviceGroupKeys = {
all: ['service-groups'] as const,
}
export const serviceGroupsQueryOptions = () =>
queryOptions({
queryKey: serviceGroupKeys.all,
queryFn: async () => {
const data = await api.get<unknown>('/api/v1/service-groups')
return serviceGroupsResponseSchema.parse(data)
},
})
export const servicesQueryOptions = () =>
queryOptions({
queryKey: serviceKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/services')
return z.array(serviceSchema).parse(data)
return z.array(serviceViewSchema).parse(data)
},
})
+334 -301
View File
@@ -1,131 +1,157 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useState } from 'react'
import { toast } from 'sonner'
import {
domainKeys,
domainsListQueryOptions,
serviceBindingKeys,
serviceBindingsQueryOptions,
serviceGroupKeys,
serviceGroupsQueryOptions,
serviceKeys,
servicesQueryOptions,
} from '@/queries'
import { api } from '@/lib/api-client'
import {
createServiceBindingSchema,
createServiceSchema,
type CreateServiceBindingInput,
type CreateServiceInput,
type ServiceBinding,
import type {
CreateServiceGroupInput,
CreateServiceWithConfigInput,
ServiceGroupView,
ServiceGroupsResponse,
ServiceView,
UpdateServiceConfigInput,
} from '@/lib/schemas'
import { PageHeader } from '@/components/page-header'
import { DataTableCard } from '@/components/data-table-card'
import { KanbanBoard } from '@/components/kanban-board'
import { ServiceBindingCard } from '@/components/service-binding-card'
import { ServiceEditSheet } from '@/components/service-edit-sheet'
import { ServiceGroupCard } from '@/components/service-group-card'
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
import { ServiceRow } from '@/components/service-row'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
import {
Field,
FieldGroup,
FieldLabel,
} from '@cfdm/ui/components/field'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@cfdm/ui/components/tabs'
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@cfdm/ui/components/empty'
import { ItemGroup } from '@cfdm/ui/components/item'
import { Spinner } from '@cfdm/ui/components/spinner'
export const Route = createFileRoute('/_auth/services')({
loader: ({ context: { queryClient } }) =>
Promise.all([
queryClient.ensureQueryData(servicesQueryOptions()),
queryClient.ensureQueryData(serviceBindingsQueryOptions()),
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
queryClient.ensureQueryData(domainsListQueryOptions()),
]),
component: ServicesPage,
})
function serviceColumnId(serviceId: number) {
return `service-${serviceId}`
function setServiceEnabled(
data: ServiceGroupsResponse,
serviceId: number,
enabled: boolean,
): ServiceGroupsResponse {
return {
groups: data.groups.map((group) => ({
...group,
services: group.services.map((service) =>
service.id === serviceId ? { ...service, enabled } : service,
),
})),
ungrouped: data.ungrouped.map((service) =>
service.id === serviceId ? { ...service, enabled } : service,
),
}
}
function parseServiceColumnId(columnId: string): number | null {
const match = columnId.match(/^service-(\d+)$/)
return match ? Number(match[1]) : null
function setGroupEnabled(
data: ServiceGroupsResponse,
groupId: number,
enabled: boolean,
): ServiceGroupsResponse {
return {
...data,
groups: data.groups.map((group) => {
if (group.id !== groupId) return group
return {
...group,
enabled,
services: enabled
? group.services
: group.services.map((service) => ({ ...service, enabled: false })),
}
}),
}
}
function ServicesPage() {
const [sheetOpen, setSheetOpen] = useState(false)
const [createSheetOpen, setCreateSheetOpen] = useState(false)
const [createGroupSheetOpen, setCreateGroupSheetOpen] = useState(false)
const [editingGroup, setEditingGroup] = useState<ServiceGroupView | null>(null)
const [editingService, setEditingService] = useState<ServiceView | null>(null)
const [savingId, setSavingId] = useState<number | null>(null)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
const [togglingGroupId, setTogglingGroupId] = useState<number | null>(null)
const queryClient = useQueryClient()
const { data: services } = useQuery(servicesQueryOptions())
const { data: bindings } = useQuery(serviceBindingsQueryOptions())
const { data, isLoading } = useQuery(serviceGroupsQueryOptions())
const { data: domains } = useQuery(domainsListQueryOptions())
const catalogForm = useForm<CreateServiceInput>({
resolver: zodResolver(createServiceSchema),
defaultValues: { name: '', slug: '' },
})
function invalidateAll() {
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all })
queryClient.invalidateQueries({ queryKey: serviceKeys.all })
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
queryClient.invalidateQueries({ queryKey: domainKeys.all })
}
const bindingForm = useForm<CreateServiceBindingInput>({
resolver: zodResolver(createServiceBindingSchema),
defaultValues: {
domain_id: '',
service_id: '',
hostname: '@',
target_ip: '',
const createGroupMutation = useMutation({
mutationFn: (body: CreateServiceGroupInput) =>
api.post('/api/v1/service-groups', body),
onSuccess: () => {
invalidateAll()
setCreateGroupSheetOpen(false)
toast.success('Группа создана')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось создать группу')
},
})
const columns = useMemo(() => {
return (
services?.map((service) => ({
id: serviceColumnId(service.id),
title: service.name,
description: service.slug,
items: bindings?.filter((b) => b.service_id === service.id) ?? [],
})) ?? []
)
}, [services, bindings])
const updateGroupMutation = useMutation({
mutationFn: ({ id, body }: { id: number; body: CreateServiceGroupInput }) =>
api.patch(`/api/v1/service-groups/${id}`, body),
onSuccess: () => {
invalidateAll()
setEditingGroup(null)
toast.success('Группа сохранена, DNS синхронизируется')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить группу')
},
})
const createServiceMutation = useMutation({
mutationFn: (body: CreateServiceInput) => api.post('/api/v1/services', body),
mutationFn: async (body: CreateServiceWithConfigInput) => {
const created = await api.post<ServiceView>('/api/v1/services', {
name: body.name,
slug: body.slug,
service_group_id: body.service_group_id ?? null,
})
const hasConfig = body.ips.length > 0 || body.domains.length > 0
if (!hasConfig) return created
return api.patch<ServiceView>(`/api/v1/services/${created.id}`, {
ips: body.ips,
...(body.domains.length > 0 ? { domains: body.domains } : {}),
service_group_id: body.service_group_id ?? null,
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: serviceKeys.all })
catalogForm.reset()
invalidateAll()
setCreateSheetOpen(false)
toast.success('Сервис создан')
},
onError: (err) => {
@@ -133,249 +159,256 @@ function ServicesPage() {
},
})
const createBindingMutation = useMutation({
mutationFn: (body: {
domain_id: number
service_id: number
hostname?: string
target_ip?: string
}) => api.post('/api/v1/service-bindings', body),
const updateServiceMutation = useMutation({
mutationFn: ({ id, body }: { id: number; body: UpdateServiceConfigInput }) =>
api.patch<ServiceView>(`/api/v1/services/${id}`, body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
queryClient.invalidateQueries({ queryKey: domainKeys.all })
bindingForm.reset({ domain_id: '', service_id: '', hostname: '@', target_ip: '' })
setSheetOpen(false)
toast.success('Привязка создана')
invalidateAll()
setEditingService(null)
toast.success('Сервис сохранён')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось создать привязку')
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить сервис')
},
onSettled: () => {
setSavingId(null)
},
})
const updateBindingMutation = useMutation({
mutationFn: ({
id,
body,
}: {
id: number
body: { service_id?: number; hostname?: string; target_ip?: string }
}) => api.patch(`/api/v1/service-bindings/${id}`, body),
const deleteServiceMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/services/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
queryClient.invalidateQueries({ queryKey: domainKeys.all })
invalidateAll()
setEditingService(null)
toast.success('Сервис удалён')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось обновить привязку')
toast.error(err instanceof Error ? err.message : 'Не удалось удалить сервис')
},
onSettled: () => {
setDeletingId(null)
},
})
function handleMove(itemId: string, _fromColumnId: string, toColumnId: string) {
const serviceId = parseServiceColumnId(toColumnId)
if (serviceId === null) return
updateBindingMutation.mutate({
id: Number(itemId),
body: { service_id: serviceId },
})
}
function handleIpChange(id: number, targetIp: string) {
updateBindingMutation.mutate({ id, body: { target_ip: targetIp } })
}
function handleHostnameChange(id: number, hostname: string) {
updateBindingMutation.mutate({ id, body: { hostname } })
}
const renderBindingCard = (binding: ServiceBinding) => (
<ServiceBindingCard
binding={binding}
onIpChange={handleIpChange}
onHostnameChange={handleHostnameChange}
/>
)
const handleCatalogSubmit = catalogForm.handleSubmit((values) => {
createServiceMutation.mutate(values)
const toggleServiceMutation = useMutation({
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
api.patch<ServiceView>(`/api/v1/services/${id}/toggle`, { enabled }),
onMutate: async ({ id, enabled }) => {
await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
serviceGroupKeys.all,
)
if (previous) {
queryClient.setQueryData(
serviceGroupKeys.all,
setServiceEnabled(previous, id, enabled),
)
}
return { previous }
},
onError: (err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(serviceGroupKeys.all, context.previous)
}
toast.error(err instanceof Error ? err.message : 'Не удалось переключить сервис')
},
onSuccess: (_data, { enabled }) => {
toast.success(
enabled
? 'Сервис включён, DNS синхронизируется с Cloudflare'
: 'Сервис выключен, DNS-записи удалены из Cloudflare',
)
},
onSettled: () => {
setTogglingServiceId(null)
invalidateAll()
},
})
const handleBindingSubmit = bindingForm.handleSubmit((values) => {
createBindingMutation.mutate({
domain_id: Number(values.domain_id),
service_id: Number(values.service_id),
hostname: values.hostname || '@',
target_ip: values.target_ip || undefined,
})
const toggleGroupMutation = useMutation({
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
api.patch<ServiceGroupsResponse>(`/api/v1/service-groups/${id}/toggle`, {
enabled,
}),
onMutate: async ({ id, enabled }) => {
await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
serviceGroupKeys.all,
)
if (previous) {
queryClient.setQueryData(
serviceGroupKeys.all,
setGroupEnabled(previous, id, enabled),
)
}
return { previous }
},
onError: (err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(serviceGroupKeys.all, context.previous)
}
toast.error(err instanceof Error ? err.message : 'Не удалось переключить группу')
},
onSuccess: (_data, { enabled }) => {
toast.success(
enabled
? 'Группа включена, DNS включённых сервисов синхронизируется'
: 'Группа выключена, DNS-записи сервисов удалены',
)
},
onSettled: () => {
setTogglingGroupId(null)
invalidateAll()
},
})
function handleSave(id: number, body: UpdateServiceConfigInput) {
setSavingId(id)
updateServiceMutation.mutate({ id, body })
}
function handleDelete(id: number) {
setDeletingId(id)
deleteServiceMutation.mutate(id)
}
function handleCreate(body: CreateServiceWithConfigInput) {
createServiceMutation.mutate(body)
}
function handleServiceToggle(serviceId: number, enabled: boolean) {
setTogglingServiceId(serviceId)
toggleServiceMutation.mutate({ id: serviceId, enabled })
}
function handleGroupToggle(groupId: number, enabled: boolean) {
setTogglingGroupId(groupId)
toggleGroupMutation.mutate({ id: groupId, enabled })
}
const groups = data?.groups ?? []
const ungrouped = data?.ungrouped ?? []
const isEmpty = groups.length === 0 && ungrouped.length === 0
return (
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<PageHeader
title="Сервисы"
description="Канбан привязок доменов к сервисам с настройкой IP через DNS"
description="Домен группы (FQDN) и FQDN сервисов синхронизируются в Cloudflare при включении"
actions={
<Button onClick={() => setSheetOpen(true)}>Добавить привязку</Button>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Добавить группу
</Button>
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button>
</div>
}
/>
<Tabs defaultValue="kanban" orientation="horizontal" className="flex w-full flex-col gap-4">
<TabsList>
<TabsTrigger value="kanban">Канбан</TabsTrigger>
<TabsTrigger value="catalog">Справочник</TabsTrigger>
</TabsList>
<TabsContent value="kanban">
<Card>
<CardHeader>
<CardTitle>Доска сервисов</CardTitle>
<CardDescription>
Перетащите привязку между колонками или отредактируйте IP прямо на карточке
</CardDescription>
</CardHeader>
<CardContent>
<KanbanBoard
columns={columns}
getItemId={(binding) => String(binding.id)}
renderCard={renderBindingCard}
renderOverlay={renderBindingCard}
onMove={handleMove}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="catalog" className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>Создать сервис</CardTitle>
<CardDescription>Добавить новый тип сервиса в справочник</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCatalogSubmit}>
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
<Field className="flex-1">
<FieldLabel htmlFor="svc-name">Название</FieldLabel>
<Input
id="svc-name"
placeholder="Название"
{...catalogForm.register('name')}
aria-invalid={!!catalogForm.formState.errors.name}
/>
</Field>
<Field className="flex-1">
<FieldLabel htmlFor="svc-slug">Slug</FieldLabel>
<Input
id="svc-slug"
placeholder="slug"
{...catalogForm.register('slug')}
aria-invalid={!!catalogForm.formState.errors.slug}
/>
</Field>
<Button type="submit" disabled={createServiceMutation.isPending}>
{createServiceMutation.isPending && (
<Spinner data-icon="inline-start" />
)}
{createServiceMutation.isPending ? 'Создание…' : 'Создать'}
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
<DataTableCard
title="Справочник сервисов"
description="Типы сервисов для привязки к доменам"
isEmpty={!services?.length}
emptyTitle="Сервисы не найдены"
emptyDescription="Создайте первый сервис в форме выше"
>
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Slug</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services?.map((service) => (
<TableRow key={service.id}>
<TableCell className="font-medium">{service.name}</TableCell>
<TableCell className="text-muted-foreground">{service.slug}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DataTableCard>
</TabsContent>
</Tabs>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Новая привязка</SheetTitle>
<SheetDescription>
Свяжите домен с сервисом и укажите IP для A-записи
</SheetDescription>
</SheetHeader>
<form onSubmit={handleBindingSubmit} className="flex flex-col gap-4 px-4">
<Field>
<FieldLabel>Домен</FieldLabel>
<Select
value={bindingForm.watch('domain_id')}
onValueChange={(value) => bindingForm.setValue('domain_id', value ?? '')}
>
<SelectTrigger>
<SelectValue placeholder="Выберите домен" />
</SelectTrigger>
<SelectContent>
{domains?.map((domain) => (
<SelectItem key={domain.id} value={String(domain.id)}>
{domain.zone_name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Сервис</FieldLabel>
<Select
value={bindingForm.watch('service_id')}
onValueChange={(value) => bindingForm.setValue('service_id', value ?? '')}
>
<SelectTrigger>
<SelectValue placeholder="Выберите сервис" />
</SelectTrigger>
<SelectContent>
{services?.map((service) => (
<SelectItem key={service.id} value={String(service.id)}>
{service.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="binding-hostname">Hostname</FieldLabel>
<Input
id="binding-hostname"
placeholder="@"
{...bindingForm.register('hostname')}
/>
</Field>
<Field>
<FieldLabel htmlFor="binding-ip">IPv4</FieldLabel>
<Input
id="binding-ip"
placeholder="192.168.1.1"
{...bindingForm.register('target_ip')}
/>
</Field>
<SheetFooter>
<Button type="submit" disabled={createBindingMutation.isPending}>
{createBindingMutation.isPending && (
<Spinner data-icon="inline-start" />
)}
Создать
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner className="size-8" />
</div>
) : isEmpty ? (
<Empty>
<EmptyHeader>
<EmptyTitle>Сервисы не найдены</EmptyTitle>
<EmptyDescription>
Создайте группу и сервис — например VPN Panel или Home Assistant.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<div className="flex flex-wrap justify-center gap-2">
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Добавить группу
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button>
</div>
</EmptyContent>
</Empty>
) : (
<div className="flex flex-col gap-4">
{groups.map((group) => (
<ServiceGroupCard
key={group.id}
group={group}
onGroupToggle={handleGroupToggle}
onServiceToggle={handleServiceToggle}
onEditService={setEditingService}
onEditGroup={setEditingGroup}
togglingGroupId={togglingGroupId}
togglingServiceId={togglingServiceId}
/>
))}
{ungrouped.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Без группы</CardTitle>
</CardHeader>
<CardContent>
<ItemGroup>
{ungrouped.map((service) => (
<ServiceRow
key={service.id}
service={service}
onToggle={handleServiceToggle}
onEdit={setEditingService}
isToggling={togglingServiceId === service.id}
/>
))}
</ItemGroup>
</CardContent>
</Card>
) : null}
</div>
)}
<ServiceEditSheet
mode="edit"
service={editingService}
groups={groups}
open={editingService !== null}
knownDomains={domains ?? []}
isSaving={editingService !== null && savingId === editingService.id}
isDeleting={editingService !== null && deletingId === editingService.id}
onOpenChange={(open) => {
if (!open) setEditingService(null)
}}
onSave={handleSave}
onDelete={handleDelete}
/>
<ServiceEditSheet
mode="create"
service={null}
groups={groups}
open={createSheetOpen}
knownDomains={domains ?? []}
isSaving={createServiceMutation.isPending}
onOpenChange={setCreateSheetOpen}
onCreate={handleCreate}
/>
<ServiceGroupEditSheet
mode="create"
group={null}
open={createGroupSheetOpen}
isSaving={createGroupMutation.isPending}
onOpenChange={setCreateGroupSheetOpen}
onCreate={(body) => createGroupMutation.mutate(body)}
/>
<ServiceGroupEditSheet
mode="edit"
group={editingGroup}
open={editingGroup !== null}
isSaving={updateGroupMutation.isPending}
onOpenChange={(open) => {
if (!open) setEditingGroup(null)
}}
onSave={(id, body) => updateGroupMutation.mutate({ id, body })}
/>
</div>
)
}
+40
View File
@@ -0,0 +1,40 @@
PRAGMA foreign_keys = OFF;
CREATE TABLE service_ips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE,
ip TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(service_id, ip)
);
CREATE INDEX idx_service_ips_service_id ON service_ips(service_id);
CREATE TABLE service_binding_records (
binding_id INTEGER NOT NULL REFERENCES service_bindings(id) ON DELETE CASCADE,
dns_record_id INTEGER NOT NULL REFERENCES dns_records(id) ON DELETE CASCADE,
PRIMARY KEY (binding_id, dns_record_id)
);
INSERT INTO service_ips (service_id, ip, created_at)
SELECT DISTINCT sb.service_id, dr.content, datetime('now')
FROM service_bindings sb
JOIN dns_records dr ON dr.id = sb.dns_record_id
WHERE dr.record_type = 'A'
AND dr.content IS NOT NULL
AND TRIM(dr.content) != ''
ON CONFLICT(service_id, ip) DO NOTHING;
INSERT INTO service_binding_records (binding_id, dns_record_id)
SELECT sb.id, sb.dns_record_id
FROM service_bindings sb
WHERE sb.dns_record_id IS NOT NULL
ON CONFLICT(binding_id, dns_record_id) DO NOTHING;
INSERT INTO services (name, slug) VALUES
('VPN Panel', 'vpn-panel'),
('VPN Node', 'vpn-node'),
('Home Assistant', 'home-assistant')
ON CONFLICT(slug) DO NOTHING;
PRAGMA foreign_keys = ON;
+29
View File
@@ -0,0 +1,29 @@
PRAGMA foreign_keys = OFF;
CREATE TABLE service_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'custom',
icon TEXT,
domain TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
ALTER TABLE services ADD COLUMN service_group_id INTEGER REFERENCES service_groups(id) ON DELETE SET NULL;
ALTER TABLE services ADD COLUMN subdomain TEXT;
ALTER TABLE services ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;
UPDATE services SET subdomain = slug WHERE subdomain IS NULL;
INSERT INTO service_groups (name, type, enabled) VALUES
('VPN', 'vpn', 1),
('Сеть', 'network', 1),
('Интернет', 'internet', 1);
UPDATE services
SET service_group_id = (SELECT id FROM service_groups WHERE type = 'vpn' LIMIT 1)
WHERE slug IN ('vpn-panel', 'vpn-node');
PRAGMA foreign_keys = ON;
@@ -0,0 +1,25 @@
CREATE TABLE service_binding_ips (
binding_id INTEGER NOT NULL REFERENCES service_bindings(id) ON DELETE CASCADE,
ip TEXT NOT NULL,
PRIMARY KEY (binding_id, ip)
);
CREATE INDEX idx_service_binding_ips_binding_id ON service_binding_ips(binding_id);
INSERT INTO service_binding_ips (binding_id, ip)
SELECT sbr.binding_id, dr.content
FROM service_binding_records sbr
JOIN dns_records dr ON dr.id = sbr.dns_record_id
WHERE dr.record_type = 'A'
AND dr.content IS NOT NULL
AND TRIM(dr.content) != ''
ON CONFLICT(binding_id, ip) DO NOTHING;
INSERT INTO service_binding_ips (binding_id, ip)
SELECT sb.id, dr.content
FROM service_bindings sb
JOIN dns_records dr ON dr.id = sb.dns_record_id
WHERE dr.record_type = 'A'
AND dr.content IS NOT NULL
AND TRIM(dr.content) != ''
ON CONFLICT(binding_id, ip) DO NOTHING;
@@ -0,0 +1,7 @@
CREATE TABLE service_group_dns_records (
group_id INTEGER NOT NULL REFERENCES service_groups(id) ON DELETE CASCADE,
dns_record_id INTEGER NOT NULL REFERENCES dns_records(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, dns_record_id)
);
CREATE INDEX idx_service_group_dns_records_group_id ON service_group_dns_records(group_id);
+1
View File
@@ -5,6 +5,7 @@ pub mod domains;
pub mod groups;
pub mod health;
pub mod service_bindings;
pub mod service_groups;
pub mod services;
pub mod subdomains;
pub mod sync;
@@ -0,0 +1,49 @@
use crate::error::AppResult;
use crate::services::service_config_service::{self, ServiceGroupBody, ToggleRequest};
use crate::state::AppState;
use axum::extract::{Path, State};
pub async fn list(
State(state): State<AppState>,
) -> AppResult<axum::Json<crate::domain::ServiceGroupsResponse>> {
Ok(axum::Json(
service_config_service::list_group_views(&state.pool).await?,
))
}
pub async fn create(
State(state): State<AppState>,
axum::Json(body): axum::Json<ServiceGroupBody>,
) -> AppResult<axum::Json<crate::domain::ServiceGroup>> {
Ok(axum::Json(
service_config_service::create_group(&state.pool, &state.cf, &body).await?,
))
}
pub async fn update(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<ServiceGroupBody>,
) -> AppResult<axum::Json<crate::domain::ServiceGroup>> {
Ok(axum::Json(
service_config_service::update_group(&state.pool, &state.cf, id, &body).await?,
))
}
pub async fn delete(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<serde_json::Value>> {
service_config_service::delete_group(&state.pool, id).await?;
Ok(axum::Json(serde_json::json!({ "deleted": true })))
}
pub async fn toggle(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<ToggleRequest>,
) -> AppResult<axum::Json<crate::domain::ServiceGroupsResponse>> {
Ok(axum::Json(
service_config_service::toggle_group(&state.pool, &state.cf, id, body.enabled).await?,
))
}
+28 -11
View File
@@ -1,5 +1,5 @@
use crate::error::AppResult;
use crate::repositories::services as service_repo;
use crate::services::service_config_service::{self, ToggleRequest, UpdateServiceConfigRequest};
use crate::state::AppState;
use axum::extract::{Path, State};
use serde::Deserialize;
@@ -8,38 +8,55 @@ use serde::Deserialize;
pub struct ServiceBody {
pub name: String,
pub slug: String,
pub service_group_id: Option<i64>,
}
pub async fn list(State(state): State<AppState>) -> AppResult<axum::Json<Vec<crate::domain::Service>>> {
Ok(axum::Json(service_repo::list(&state.pool).await?))
pub async fn list(State(state): State<AppState>) -> AppResult<axum::Json<Vec<crate::domain::ServiceView>>> {
Ok(axum::Json(service_config_service::list_views(&state.pool).await?))
}
pub async fn get_one(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<crate::domain::Service>> {
Ok(axum::Json(service_repo::get(&state.pool, id).await?))
) -> AppResult<axum::Json<crate::domain::ServiceView>> {
Ok(axum::Json(service_config_service::get_view(&state.pool, id).await?))
}
pub async fn create(
State(state): State<AppState>,
axum::Json(body): axum::Json<ServiceBody>,
) -> AppResult<axum::Json<crate::domain::Service>> {
Ok(axum::Json(service_repo::create(&state.pool, &body.name, &body.slug).await?))
) -> AppResult<axum::Json<crate::domain::ServiceView>> {
let service = crate::repositories::services::create(&state.pool, &body.name, &body.slug).await?;
if let Some(group_id) = body.service_group_id {
crate::repositories::services::set_group(&state.pool, service.id, Some(group_id)).await?;
}
Ok(axum::Json(service_config_service::get_view(&state.pool, service.id).await?))
}
pub async fn update(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<ServiceBody>,
) -> AppResult<axum::Json<crate::domain::Service>> {
Ok(axum::Json(service_repo::update(&state.pool, id, &body.name, &body.slug).await?))
axum::Json(body): axum::Json<UpdateServiceConfigRequest>,
) -> AppResult<axum::Json<crate::domain::ServiceView>> {
Ok(axum::Json(
service_config_service::update_config(&state.pool, &state.cf, id, body).await?,
))
}
pub async fn delete(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<serde_json::Value>> {
service_repo::delete(&state.pool, id).await?;
crate::repositories::services::delete(&state.pool, id).await?;
Ok(axum::Json(serde_json::json!({ "deleted": true })))
}
pub async fn toggle(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<ToggleRequest>,
) -> AppResult<axum::Json<crate::domain::ServiceView>> {
Ok(axum::Json(
service_config_service::toggle_service(&state.pool, &state.cf, id, body.enabled).await?,
))
}
+14 -1
View File
@@ -1,4 +1,4 @@
use super::handlers::{auth, certificates, dns, domains, groups, health, service_bindings, services, subdomains, sync};
use super::handlers::{auth, certificates, dns, domains, groups, health, service_bindings, service_groups, services, subdomains, sync};
use crate::state::AppState;
use axum::{
middleware,
@@ -33,6 +33,19 @@ pub fn create_router(state: AppState) -> Router {
.patch(services::update)
.delete(services::delete),
)
.route("/services/{id}/toggle", axum::routing::patch(services::toggle))
.route(
"/service-groups",
get(service_groups::list).post(service_groups::create),
)
.route(
"/service-groups/{id}",
axum::routing::patch(service_groups::update).delete(service_groups::delete),
)
.route(
"/service-groups/{id}/toggle",
axum::routing::patch(service_groups::toggle),
)
.route(
"/service-bindings",
get(service_bindings::list).post(service_bindings::create),
+62
View File
@@ -9,11 +9,47 @@ pub struct Group {
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct ServiceGroup {
pub id: i64,
pub name: String,
#[serde(rename = "type")]
pub group_type: String,
pub icon: Option<String>,
pub domain: Option<String>,
pub enabled: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceGroupView {
pub id: i64,
pub name: String,
#[serde(rename = "type")]
pub group_type: String,
pub icon: Option<String>,
pub domain: Option<String>,
pub enabled: bool,
pub created_at: String,
pub updated_at: String,
pub services: Vec<ServiceView>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceGroupsResponse {
pub groups: Vec<ServiceGroupView>,
pub ungrouped: Vec<ServiceView>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Service {
pub id: i64,
pub name: String,
pub slug: String,
pub service_group_id: Option<i64>,
pub subdomain: String,
pub enabled: bool,
pub created_at: String,
pub updated_at: String,
}
@@ -101,6 +137,32 @@ pub struct ServiceBindingView {
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDomainBindingView {
pub binding_id: i64,
pub domain_id: i64,
pub zone_name: String,
pub hostname: String,
pub fqdn: String,
pub target_ips: Vec<String>,
pub sync_status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceView {
pub id: i64,
pub name: String,
pub slug: String,
pub service_group_id: Option<i64>,
pub subdomain: String,
pub enabled: bool,
pub computed_fqdn: Option<String>,
pub created_at: String,
pub updated_at: String,
pub ips: Vec<String>,
pub domains: Vec<ServiceDomainBindingView>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct GroupWithStats {
pub id: i64,
+4
View File
@@ -17,6 +17,10 @@ use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
rustls::crypto::ring::default_provider()
.install_default()
.map_err(|_| "failed to install rustls ring crypto provider")?;
load_dotenv();
let config = Config::from_env().map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
+9
View File
@@ -48,6 +48,15 @@ pub async fn list_enriched(pool: &SqlitePool, group_id: Option<i64>) -> AppResul
}
}
pub async fn find_by_zone_name(pool: &SqlitePool, zone_name: &str) -> AppResult<Option<Domain>> {
Ok(sqlx::query_as::<_, Domain>(
"SELECT * FROM domains WHERE LOWER(zone_name) = LOWER(?) LIMIT 1",
)
.bind(zone_name.trim())
.fetch_optional(pool)
.await?)
}
pub async fn get(pool: &SqlitePool, id: i64) -> AppResult<Domain> {
sqlx::query_as::<_, Domain>("SELECT * FROM domains WHERE id = ?")
.bind(id)
+5
View File
@@ -2,7 +2,12 @@ pub mod certificates;
pub mod dns_records;
pub mod domains;
pub mod groups;
pub mod service_binding_ips;
pub mod service_binding_records;
pub mod service_bindings;
pub mod service_group_dns_records;
pub mod service_groups;
pub mod service_ips;
pub mod services;
pub mod subdomains;
pub mod sync_jobs;
@@ -0,0 +1,32 @@
use crate::error::AppResult;
use sqlx::SqlitePool;
pub async fn list_for_binding(pool: &SqlitePool, binding_id: i64) -> AppResult<Vec<String>> {
Ok(sqlx::query_scalar::<_, String>(
"SELECT ip FROM service_binding_ips WHERE binding_id = ? ORDER BY ip",
)
.bind(binding_id)
.fetch_all(pool)
.await?)
}
pub async fn replace_for_binding(
pool: &SqlitePool,
binding_id: i64,
ips: &[String],
) -> AppResult<()> {
sqlx::query("DELETE FROM service_binding_ips WHERE binding_id = ?")
.bind(binding_id)
.execute(pool)
.await?;
for ip in ips {
sqlx::query("INSERT INTO service_binding_ips (binding_id, ip) VALUES (?, ?)")
.bind(binding_id)
.bind(ip)
.execute(pool)
.await?;
}
Ok(())
}
@@ -0,0 +1,59 @@
use crate::domain::DnsRecord;
use crate::error::AppResult;
use sqlx::SqlitePool;
pub async fn list_records_for_binding(
pool: &SqlitePool,
binding_id: i64,
) -> AppResult<Vec<DnsRecord>> {
Ok(sqlx::query_as::<_, DnsRecord>(
r#"
SELECT dr.*
FROM service_binding_records sbr
JOIN dns_records dr ON dr.id = sbr.dns_record_id
WHERE sbr.binding_id = ?
ORDER BY dr.content
"#,
)
.bind(binding_id)
.fetch_all(pool)
.await?)
}
pub async fn link(pool: &SqlitePool, binding_id: i64, dns_record_id: i64) -> AppResult<()> {
sqlx::query(
"INSERT INTO service_binding_records (binding_id, dns_record_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
)
.bind(binding_id)
.bind(dns_record_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn unlink(pool: &SqlitePool, binding_id: i64, dns_record_id: i64) -> AppResult<()> {
sqlx::query(
"DELETE FROM service_binding_records WHERE binding_id = ? AND dns_record_id = ?",
)
.bind(binding_id)
.bind(dns_record_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn unlink_all_for_binding(pool: &SqlitePool, binding_id: i64) -> AppResult<Vec<i64>> {
let ids = sqlx::query_scalar::<_, i64>(
"SELECT dns_record_id FROM service_binding_records WHERE binding_id = ?",
)
.bind(binding_id)
.fetch_all(pool)
.await?;
sqlx::query("DELETE FROM service_binding_records WHERE binding_id = ?")
.bind(binding_id)
.execute(pool)
.await?;
Ok(ids)
}
@@ -32,6 +32,33 @@ pub async fn list_all(pool: &SqlitePool) -> AppResult<Vec<ServiceBindingView>> {
.await?)
}
pub async fn list_by_service(pool: &SqlitePool, service_id: i64) -> AppResult<Vec<ServiceBindingView>> {
let sql = format!("{VIEW_SELECT} WHERE sb.service_id = ? ORDER BY d.zone_name, sb.hostname");
Ok(sqlx::query_as::<_, ServiceBindingView>(&sql)
.bind(service_id)
.fetch_all(pool)
.await?)
}
pub async fn find_for_service_domain_hostname(
pool: &SqlitePool,
service_id: i64,
domain_id: i64,
hostname: &str,
) -> AppResult<Option<ServiceBinding>> {
Ok(sqlx::query_as::<_, ServiceBinding>(
r#"
SELECT * FROM service_bindings
WHERE service_id = ? AND domain_id = ? AND hostname = ?
"#,
)
.bind(service_id)
.bind(domain_id)
.bind(hostname)
.fetch_optional(pool)
.await?)
}
pub async fn list_by_domain(pool: &SqlitePool, domain_id: i64) -> AppResult<Vec<ServiceBindingView>> {
let sql = format!("{VIEW_SELECT} WHERE sb.domain_id = ? ORDER BY s.name");
Ok(sqlx::query_as::<_, ServiceBindingView>(&sql)
@@ -122,6 +149,36 @@ pub async fn set_dns_record_id(pool: &SqlitePool, id: i64, dns_record_id: Option
Ok(())
}
pub async fn bindings_to_remove(
pool: &SqlitePool,
service_id: i64,
keep_ids: &[i64],
) -> AppResult<Vec<ServiceBinding>> {
let bindings = sqlx::query_as::<_, ServiceBinding>(
"SELECT * FROM service_bindings WHERE service_id = ?",
)
.bind(service_id)
.fetch_all(pool)
.await?;
Ok(bindings
.into_iter()
.filter(|binding| !keep_ids.contains(&binding.id))
.collect())
}
pub async fn delete_for_service_except(
pool: &SqlitePool,
service_id: i64,
keep_ids: &[i64],
) -> AppResult<()> {
let to_remove = bindings_to_remove(pool, service_id, keep_ids).await?;
for binding in to_remove {
delete(pool, binding.id).await?;
}
Ok(())
}
pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> {
let affected = sqlx::query("DELETE FROM service_bindings WHERE id = ?")
.bind(id)
@@ -0,0 +1,43 @@
use crate::domain::DnsRecord;
use crate::error::AppResult;
use sqlx::SqlitePool;
pub async fn list_records_for_group(
pool: &SqlitePool,
group_id: i64,
) -> AppResult<Vec<DnsRecord>> {
Ok(sqlx::query_as::<_, DnsRecord>(
r#"
SELECT dr.*
FROM service_group_dns_records sgdr
JOIN dns_records dr ON dr.id = sgdr.dns_record_id
WHERE sgdr.group_id = ?
ORDER BY dr.content
"#,
)
.bind(group_id)
.fetch_all(pool)
.await?)
}
pub async fn link(pool: &SqlitePool, group_id: i64, dns_record_id: i64) -> AppResult<()> {
sqlx::query(
"INSERT INTO service_group_dns_records (group_id, dns_record_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
)
.bind(group_id)
.bind(dns_record_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn unlink(pool: &SqlitePool, group_id: i64, dns_record_id: i64) -> AppResult<()> {
sqlx::query(
"DELETE FROM service_group_dns_records WHERE group_id = ? AND dns_record_id = ?",
)
.bind(group_id)
.bind(dns_record_id)
.execute(pool)
.await?;
Ok(())
}
@@ -0,0 +1,92 @@
use crate::domain::ServiceGroup;
use crate::error::{AppError, AppResult};
use sqlx::SqlitePool;
pub async fn list(pool: &SqlitePool) -> AppResult<Vec<ServiceGroup>> {
Ok(sqlx::query_as::<_, ServiceGroup>(
"SELECT id, name, type AS group_type, icon, domain, enabled, created_at, updated_at FROM service_groups ORDER BY name",
)
.fetch_all(pool)
.await?)
}
pub async fn get(pool: &SqlitePool, id: i64) -> AppResult<ServiceGroup> {
sqlx::query_as::<_, ServiceGroup>(
"SELECT id, name, type AS group_type, icon, domain, enabled, created_at, updated_at FROM service_groups WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("service group {id}")))
}
pub async fn create(
pool: &SqlitePool,
name: &str,
group_type: &str,
icon: Option<&str>,
domain: Option<&str>,
) -> AppResult<ServiceGroup> {
let id = sqlx::query_scalar::<_, i64>(
"INSERT INTO service_groups (name, type, icon, domain) VALUES (?, ?, ?, ?) RETURNING id",
)
.bind(name)
.bind(group_type)
.bind(icon)
.bind(domain)
.fetch_one(pool)
.await?;
get(pool, id).await
}
pub async fn update(
pool: &SqlitePool,
id: i64,
name: &str,
group_type: &str,
icon: Option<&str>,
domain: Option<&str>,
) -> AppResult<ServiceGroup> {
let affected = sqlx::query(
"UPDATE service_groups SET name = ?, type = ?, icon = ?, domain = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(name)
.bind(group_type)
.bind(icon)
.bind(domain)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service group {id}")));
}
get(pool, id).await
}
pub async fn set_enabled(pool: &SqlitePool, id: i64, enabled: bool) -> AppResult<ServiceGroup> {
let affected = sqlx::query(
"UPDATE service_groups SET enabled = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(enabled)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service group {id}")));
}
get(pool, id).await
}
pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> {
let affected = sqlx::query("DELETE FROM service_groups WHERE id = ?")
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service group {id}")));
}
Ok(())
}
+28
View File
@@ -0,0 +1,28 @@
use crate::error::AppResult;
use sqlx::SqlitePool;
pub async fn list_by_service(pool: &SqlitePool, service_id: i64) -> AppResult<Vec<String>> {
let rows = sqlx::query_scalar::<_, String>(
"SELECT ip FROM service_ips WHERE service_id = ? ORDER BY ip",
)
.bind(service_id)
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn replace_for_service(pool: &SqlitePool, service_id: i64, ips: &[String]) -> AppResult<()> {
sqlx::query("DELETE FROM service_ips WHERE service_id = ?")
.bind(service_id)
.execute(pool)
.await?;
for ip in ips {
sqlx::query("INSERT INTO service_ips (service_id, ip) VALUES (?, ?)")
.bind(service_id)
.bind(ip)
.execute(pool)
.await?;
}
Ok(())
}
+51 -2
View File
@@ -8,6 +8,23 @@ pub async fn list(pool: &SqlitePool) -> AppResult<Vec<Service>> {
.await?)
}
pub async fn list_by_group(pool: &SqlitePool, group_id: i64) -> AppResult<Vec<Service>> {
Ok(sqlx::query_as::<_, Service>(
"SELECT * FROM services WHERE service_group_id = ? ORDER BY name",
)
.bind(group_id)
.fetch_all(pool)
.await?)
}
pub async fn list_ungrouped(pool: &SqlitePool) -> AppResult<Vec<Service>> {
Ok(sqlx::query_as::<_, Service>(
"SELECT * FROM services WHERE service_group_id IS NULL ORDER BY name",
)
.fetch_all(pool)
.await?)
}
pub async fn get(pool: &SqlitePool, id: i64) -> AppResult<Service> {
sqlx::query_as::<_, Service>("SELECT * FROM services WHERE id = ?")
.bind(id)
@@ -18,10 +35,11 @@ pub async fn get(pool: &SqlitePool, id: i64) -> AppResult<Service> {
pub async fn create(pool: &SqlitePool, name: &str, slug: &str) -> AppResult<Service> {
let id = sqlx::query_scalar::<_, i64>(
"INSERT INTO services (name, slug) VALUES (?, ?) RETURNING id",
"INSERT INTO services (name, slug, subdomain) VALUES (?, ?, ?) RETURNING id",
)
.bind(name)
.bind(slug)
.bind(slug)
.fetch_one(pool)
.await?;
get(pool, id).await
@@ -29,10 +47,41 @@ pub async fn create(pool: &SqlitePool, name: &str, slug: &str) -> AppResult<Serv
pub async fn update(pool: &SqlitePool, id: i64, name: &str, slug: &str) -> AppResult<Service> {
let affected = sqlx::query(
"UPDATE services SET name = ?, slug = ?, updated_at = datetime('now') WHERE id = ?",
"UPDATE services SET name = ?, slug = ?, subdomain = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(name)
.bind(slug)
.bind(slug)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service {id}")));
}
get(pool, id).await
}
pub async fn set_enabled(pool: &SqlitePool, id: i64, enabled: bool) -> AppResult<Service> {
let affected = sqlx::query(
"UPDATE services SET enabled = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(enabled)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service {id}")));
}
get(pool, id).await
}
pub async fn set_group(pool: &SqlitePool, id: i64, group_id: Option<i64>) -> AppResult<Service> {
let affected = sqlx::query(
"UPDATE services SET service_group_id = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(group_id)
.bind(id)
.execute(pool)
.await?
+1
View File
@@ -4,4 +4,5 @@ pub mod certificate_service;
pub mod dns_service;
pub mod domain_service;
pub mod group_service;
pub mod service_config_service;
pub mod sync_service;
@@ -0,0 +1,867 @@
use crate::cloudflare::CloudflareClient;
use crate::domain::{
ServiceDomainBindingView, ServiceGroupView, ServiceGroupsResponse, ServiceView, SYNC_ERROR,
SYNC_PENDING_PUSH, SYNC_SYNCED,
};
use crate::error::{AppError, AppResult};
use crate::repositories::{
domains, service_binding_ips, service_binding_records, service_bindings, service_group_dns_records,
service_groups, service_ips, services as service_repo,
};
use crate::services::dns_service::{self, CreateDnsRequest, UpdateDnsRequest};
use crate::services::domain_service;
use serde::Deserialize;
use sqlx::SqlitePool;
#[derive(Debug, Deserialize)]
pub struct ServiceDomainInput {
pub fqdn: String,
pub target_ips: Option<Vec<String>>,
pub target_ip: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ToggleRequest {
pub enabled: bool,
}
#[derive(Debug, Deserialize)]
pub struct ServiceGroupBody {
pub name: String,
#[serde(rename = "type")]
pub group_type: Option<String>,
pub icon: Option<String>,
pub domain: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateServiceConfigRequest {
pub name: Option<String>,
pub slug: Option<String>,
pub service_group_id: Option<Option<i64>>,
pub ips: Option<Vec<String>>,
pub domains: Option<Vec<ServiceDomainInput>>,
}
pub fn fqdn_to_display(hostname: &str, zone_name: &str) -> String {
if hostname == "@" {
zone_name.to_string()
} else {
format!("{hostname}.{zone_name}")
}
}
pub fn parse_fqdn(fqdn: &str, known_zones: &[String]) -> AppResult<(String, String)> {
let fqdn = fqdn.trim().to_lowercase();
if fqdn.is_empty() {
return Err(AppError::Validation("укажите FQDN".into()));
}
let mut zones: Vec<String> = known_zones.to_vec();
zones.sort_by_key(|z| std::cmp::Reverse(z.len()));
for zone in zones {
let zone_lower = zone.to_lowercase();
if fqdn == zone_lower {
return Ok((zone, "@".to_string()));
}
let suffix = format!(".{zone_lower}");
if let Some(prefix) = fqdn.strip_suffix(&suffix) {
if !prefix.is_empty() {
return Ok((zone, prefix.to_string()));
}
}
}
Err(AppError::Validation(format!(
"не удалось определить зону для «{fqdn}» — зона должна существовать в Cloudflare"
)))
}
fn normalize_ips(ips: &[String]) -> Vec<String> {
let mut out = Vec::new();
for ip in ips {
let trimmed = ip.trim();
if trimmed.is_empty() {
continue;
}
if !is_valid_ipv4(trimmed) {
continue;
}
if !out.iter().any(|v: &String| v == trimmed) {
out.push(trimmed.to_string());
}
}
out.sort();
out
}
fn is_valid_ipv4(ip: &str) -> bool {
let parts: Vec<&str> = ip.split('.').collect();
if parts.len() != 4 {
return false;
}
parts.iter().all(|p| p.parse::<u16>().ok().is_some_and(|n| n <= 255))
}
fn aggregate_sync_status(statuses: &[String]) -> Option<String> {
if statuses.is_empty() {
return None;
}
if statuses.iter().any(|s| s == SYNC_ERROR) {
return Some(SYNC_ERROR.into());
}
if statuses.iter().any(|s| s == SYNC_PENDING_PUSH) {
return Some(SYNC_PENDING_PUSH.into());
}
if statuses.iter().all(|s| s == SYNC_SYNCED) {
return Some(SYNC_SYNCED.into());
}
statuses.first().cloned()
}
async fn collect_known_zones(pool: &SqlitePool, cf: &CloudflareClient) -> AppResult<Vec<String>> {
let db_domains = domains::list(pool, None).await?;
let mut zones: Vec<String> = db_domains.into_iter().map(|d| d.zone_name).collect();
let cf_zones = cf.list_zones().await?;
for zone in cf_zones {
if !zones
.iter()
.any(|name| name.eq_ignore_ascii_case(&zone.name))
{
zones.push(zone.name);
}
}
Ok(zones)
}
pub async fn list_views(pool: &SqlitePool) -> AppResult<Vec<ServiceView>> {
let services = service_repo::list(pool).await?;
let mut views = Vec::with_capacity(services.len());
for service in services {
views.push(build_view_for_service(pool, service.id).await?);
}
Ok(views)
}
pub async fn get_view(pool: &SqlitePool, id: i64) -> AppResult<ServiceView> {
service_repo::get(pool, id).await?;
build_view_for_service(pool, id).await
}
pub async fn list_group_views(pool: &SqlitePool) -> AppResult<ServiceGroupsResponse> {
let groups = service_groups::list(pool).await?;
let mut group_views = Vec::with_capacity(groups.len());
for group in groups {
let services = service_repo::list_by_group(pool, group.id).await?;
let mut service_views = Vec::with_capacity(services.len());
for service in services {
service_views.push(build_view(pool, service.id).await?);
}
group_views.push(ServiceGroupView {
id: group.id,
name: group.name,
group_type: group.group_type,
icon: group.icon,
domain: group.domain,
enabled: group.enabled,
created_at: group.created_at,
updated_at: group.updated_at,
services: service_views,
});
}
let ungrouped_services = service_repo::list_ungrouped(pool).await?;
let mut ungrouped = Vec::with_capacity(ungrouped_services.len());
for service in ungrouped_services {
ungrouped.push(build_view(pool, service.id).await?);
}
Ok(ServiceGroupsResponse {
groups: group_views,
ungrouped,
})
}
async fn build_view(pool: &SqlitePool, service_id: i64) -> AppResult<ServiceView> {
let service = service_repo::get(pool, service_id).await?;
let ips = service_ips::list_by_service(pool, service_id).await?;
let bindings = service_bindings::list_by_service(pool, service_id).await?;
let mut domain_views = Vec::with_capacity(bindings.len());
for binding in bindings {
let records = service_binding_records::list_records_for_binding(pool, binding.id).await?;
let statuses: Vec<String> = records.iter().map(|r| r.sync_status.clone()).collect();
let target_ips = service_binding_ips::list_for_binding(pool, binding.id).await?;
domain_views.push(ServiceDomainBindingView {
binding_id: binding.id,
domain_id: binding.domain_id,
zone_name: binding.zone_name.clone(),
hostname: binding.hostname.clone(),
fqdn: fqdn_to_display(&binding.hostname, &binding.zone_name),
target_ips,
sync_status: aggregate_sync_status(&statuses),
});
}
Ok(ServiceView {
id: service.id,
name: service.name,
slug: service.slug,
service_group_id: service.service_group_id,
subdomain: service.subdomain.clone(),
enabled: service.enabled,
computed_fqdn: None,
created_at: service.created_at,
updated_at: service.updated_at,
ips,
domains: domain_views,
})
}
async fn build_view_for_service(pool: &SqlitePool, service_id: i64) -> AppResult<ServiceView> {
build_view(pool, service_id).await
}
async fn cleanup_service_dns_only(
pool: &SqlitePool,
cf: &CloudflareClient,
service_id: i64,
) -> AppResult<()> {
let bindings = service_bindings::list_by_service(pool, service_id).await?;
for binding in bindings {
cleanup_binding_dns(pool, cf, binding.id, binding.domain_id, &binding.hostname).await?;
}
Ok(())
}
/// DNS в Cloudflare публикуется только для включённого сервиса (и группы, если сервис в группе).
async fn should_push_dns_to_cloudflare(
pool: &SqlitePool,
service: &crate::domain::Service,
) -> AppResult<bool> {
if !service.enabled {
return Ok(false);
}
let Some(group_id) = service.service_group_id else {
return Ok(true);
};
let group = service_groups::get(pool, group_id).await?;
Ok(group.enabled)
}
/// Синхронизирует DNS по явным привязкам FQDN → target IP сервиса.
async fn sync_service_bindings_to_dns(
pool: &SqlitePool,
cf: &CloudflareClient,
service_id: i64,
) -> AppResult<()> {
let ips = service_ips::list_by_service(pool, service_id).await?;
if ips.is_empty() {
return Err(AppError::Validation(
"добавьте IP-адреса в пул сервиса".into(),
));
}
let bindings = service_bindings::list_by_service(pool, service_id).await?;
if bindings.is_empty() {
return Err(AppError::Validation(
"настройте FQDN в редакторе сервиса".into(),
));
}
for binding in bindings {
let target_ips = service_binding_ips::list_for_binding(pool, binding.id).await?;
if target_ips.is_empty() {
return Err(AppError::Validation(format!(
"укажите IP для {}",
fqdn_to_display(&binding.hostname, &binding.zone_name)
)));
}
validate_target_ips_in_pool(&target_ips, &ips)?;
sync_binding_dns(
pool,
cf,
binding.id,
binding.domain_id,
&binding.hostname,
&target_ips,
)
.await?;
}
Ok(())
}
async fn collect_group_dns_ips(pool: &SqlitePool, group_id: i64) -> AppResult<Vec<String>> {
let services = service_repo::list_by_group(pool, group_id).await?;
let mut ips = Vec::new();
for service in services {
if !service.enabled {
continue;
}
let bindings = service_bindings::list_by_service(pool, service.id).await?;
for binding in bindings {
for ip in service_binding_ips::list_for_binding(pool, binding.id).await? {
if !ips.iter().any(|v| v == &ip) {
ips.push(ip);
}
}
}
}
ips.sort();
Ok(ips)
}
async fn sync_group_domain_dns_records(
pool: &SqlitePool,
cf: &CloudflareClient,
group_id: i64,
domain_id: i64,
hostname: &str,
desired_ips: &[String],
) -> AppResult<()> {
let existing_records = service_group_dns_records::list_records_for_group(pool, group_id).await?;
for record in &existing_records {
if !desired_ips.contains(&record.content) {
service_group_dns_records::unlink(pool, group_id, record.id).await?;
dns_service::delete_record(pool, cf, domain_id, record.id).await?;
}
}
if desired_ips.is_empty() {
return Ok(());
}
let refreshed = service_group_dns_records::list_records_for_group(pool, group_id).await?;
for ip in desired_ips {
if let Some(record) = refreshed.iter().find(|r| r.content == *ip) {
if record.name != hostname {
dns_service::update(
pool,
cf,
domain_id,
record.id,
UpdateDnsRequest {
record_type: Some("A".into()),
name: Some(hostname.to_string()),
content: Some(ip.clone()),
ttl: None,
proxied: Some(false),
priority: None,
},
)
.await?;
}
continue;
}
let record = dns_service::create(
pool,
cf,
domain_id,
CreateDnsRequest {
record_type: "A".into(),
name: hostname.to_string(),
content: ip.clone(),
ttl: Some(1),
proxied: Some(false),
priority: None,
},
)
.await?;
service_group_dns_records::link(pool, group_id, record.id).await?;
}
Ok(())
}
async fn cleanup_group_domain_dns(pool: &SqlitePool, cf: &CloudflareClient, group_id: i64) -> AppResult<()> {
let group = service_groups::get(pool, group_id).await?;
let Some(domain_value) = group
.domain
.as_deref()
.map(str::trim)
.filter(|d| !d.is_empty())
else {
return Ok(());
};
let known_zones = collect_known_zones(pool, cf).await?;
let (zone_name, hostname) = parse_fqdn(domain_value, &known_zones)?;
let domain_id = resolve_domain_id(pool, cf, &zone_name).await?;
sync_group_domain_dns_records(pool, cf, group_id, domain_id, &hostname, &[]).await
}
async fn sync_group_domain_dns(pool: &SqlitePool, cf: &CloudflareClient, group_id: i64) -> AppResult<()> {
let group = service_groups::get(pool, group_id).await?;
if !group.enabled {
return cleanup_group_domain_dns(pool, cf, group_id).await;
}
let Some(domain_value) = group
.domain
.as_deref()
.map(str::trim)
.filter(|d| !d.is_empty())
else {
return Ok(());
};
let known_zones = collect_known_zones(pool, cf).await?;
let (zone_name, hostname) = parse_fqdn(domain_value, &known_zones)?;
let domain_id = resolve_domain_id(pool, cf, &zone_name).await?;
let desired_ips = collect_group_dns_ips(pool, group_id).await?;
sync_group_domain_dns_records(
pool,
cf,
group_id,
domain_id,
&hostname,
&desired_ips,
)
.await
}
async fn sync_group_domain_for_service(pool: &SqlitePool, cf: &CloudflareClient, service_id: i64) -> AppResult<()> {
let service = service_repo::get(pool, service_id).await?;
let Some(group_id) = service.service_group_id else {
return Ok(());
};
sync_group_domain_dns(pool, cf, group_id).await
}
async fn sync_enabled_services_in_group(
pool: &SqlitePool,
cf: &CloudflareClient,
group_id: i64,
) -> AppResult<()> {
let group = service_groups::get(pool, group_id).await?;
if !group.enabled {
return Ok(());
}
if group
.domain
.as_deref()
.map(str::trim)
.filter(|d| !d.is_empty())
.is_none()
{
return Ok(());
}
let services = service_repo::list_by_group(pool, group_id).await?;
for service in services {
if service.enabled {
sync_service_bindings_to_dns(pool, cf, service.id).await?;
}
}
sync_group_domain_dns(pool, cf, group_id).await?;
Ok(())
}
async fn resolve_domain_id(
pool: &SqlitePool,
cf: &CloudflareClient,
zone_name: &str,
) -> AppResult<i64> {
let zone_name = zone_name.trim();
if zone_name.is_empty() {
return Err(AppError::Validation("укажите имя зоны".into()));
}
if let Some(domain) = domains::find_by_zone_name(pool, zone_name).await? {
return Ok(domain.id);
}
let created = domain_service::create_domain(pool, cf, None, zone_name).await?;
Ok(created.id)
}
async fn sync_binding_dns(
pool: &SqlitePool,
cf: &CloudflareClient,
binding_id: i64,
domain_id: i64,
hostname: &str,
desired_ips: &[String],
) -> AppResult<()> {
let existing_records =
service_binding_records::list_records_for_binding(pool, binding_id).await?;
for record in &existing_records {
if !desired_ips.contains(&record.content) {
service_binding_records::unlink(pool, binding_id, record.id).await?;
dns_service::delete_record(pool, cf, domain_id, record.id).await?;
}
}
if desired_ips.is_empty() {
service_bindings::set_dns_record_id(pool, binding_id, None).await?;
return Ok(());
}
let refreshed = service_binding_records::list_records_for_binding(pool, binding_id).await?;
let mut primary_id: Option<i64> = None;
for ip in desired_ips {
let record_id = if let Some(record) = refreshed.iter().find(|r| r.content == *ip) {
if record.name != hostname {
dns_service::update(
pool,
cf,
domain_id,
record.id,
UpdateDnsRequest {
record_type: Some("A".into()),
name: Some(hostname.to_string()),
content: Some(ip.clone()),
ttl: None,
proxied: Some(false),
priority: None,
},
)
.await?;
}
record.id
} else {
let record = dns_service::create(
pool,
cf,
domain_id,
CreateDnsRequest {
record_type: "A".into(),
name: hostname.to_string(),
content: ip.clone(),
ttl: Some(1),
proxied: Some(false),
priority: None,
},
)
.await?;
service_binding_records::link(pool, binding_id, record.id).await?;
record.id
};
if primary_id.is_none() {
primary_id = Some(record_id);
}
}
service_bindings::set_dns_record_id(pool, binding_id, primary_id).await?;
Ok(())
}
async fn cleanup_binding_dns(
pool: &SqlitePool,
cf: &CloudflareClient,
binding_id: i64,
domain_id: i64,
hostname: &str,
) -> AppResult<()> {
sync_binding_dns(pool, cf, binding_id, domain_id, hostname, &[]).await
}
fn binding_target_ips(input: &ServiceDomainInput) -> AppResult<Vec<String>> {
let raw = if let Some(ips) = &input.target_ips {
ips.clone()
} else if let Some(ip) = input.target_ip.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
vec![ip.to_string()]
} else {
Vec::new()
};
let normalized = normalize_ips(&raw);
if !raw.is_empty() && normalized.is_empty() {
return Err(AppError::Validation(
"некорректные IP в привязке домена".into(),
));
}
Ok(normalized)
}
fn validate_target_ips_in_pool(target_ips: &[String], ips: &[String]) -> AppResult<()> {
for ip in target_ips {
if !is_valid_ipv4(ip) {
return Err(AppError::Validation(format!("некорректный IPv4: {ip}")));
}
if !ips.iter().any(|pool_ip| pool_ip == ip) {
return Err(AppError::Validation(format!(
"IP {ip} не входит в пул адресов сервиса"
)));
}
}
Ok(())
}
pub async fn update_config(
pool: &SqlitePool,
cf: &CloudflareClient,
id: i64,
req: UpdateServiceConfigRequest,
) -> AppResult<ServiceView> {
if let (Some(name), Some(slug)) = (&req.name, &req.slug) {
service_repo::update(pool, id, name, slug).await?;
} else if let Some(name) = &req.name {
let existing = service_repo::get(pool, id).await?;
service_repo::update(pool, id, name, &existing.slug).await?;
} else if let Some(slug) = &req.slug {
let existing = service_repo::get(pool, id).await?;
service_repo::update(pool, id, &existing.name, slug).await?;
}
if let Some(group_id) = req.service_group_id {
service_repo::set_group(pool, id, group_id).await?;
}
let ips_updated = req.ips.is_some();
let known_zones = collect_known_zones(pool, cf).await?;
let ips = if let Some(ref raw_ips) = req.ips {
normalize_ips(raw_ips)
} else {
service_ips::list_by_service(pool, id).await?
};
if ips_updated {
service_ips::replace_for_service(pool, id, &ips).await?;
}
let mut kept_binding_ids = Vec::new();
let service = service_repo::get(pool, id).await?;
let push_dns = should_push_dns_to_cloudflare(pool, &service).await?;
if let Some(domain_inputs) = req.domains {
if !domain_inputs.is_empty() {
for input in domain_inputs {
let fqdn = input.fqdn.trim();
if fqdn.is_empty() {
continue;
}
let target_ips = binding_target_ips(&input)?;
validate_target_ips_in_pool(&target_ips, &ips)?;
let (zone_name, hostname) = parse_fqdn(fqdn, &known_zones)?;
let domain_id = resolve_domain_id(pool, cf, &zone_name).await?;
let binding = if let Some(existing) =
service_bindings::find_for_service_domain_hostname(pool, id, domain_id, &hostname)
.await?
{
existing
} else {
service_bindings::insert(pool, domain_id, id, &hostname, None).await?
};
kept_binding_ids.push(binding.id);
service_binding_ips::replace_for_binding(pool, binding.id, &target_ips).await?;
if push_dns {
sync_binding_dns(
pool,
cf,
binding.id,
domain_id,
&hostname,
&target_ips,
)
.await?;
}
}
let removed = service_bindings::bindings_to_remove(pool, id, &kept_binding_ids).await?;
for binding in &removed {
cleanup_binding_dns(pool, cf, binding.id, binding.domain_id, &binding.hostname)
.await?;
}
service_bindings::delete_for_service_except(pool, id, &kept_binding_ids).await?;
}
} else if ips_updated {
let bindings = service_bindings::list_by_service(pool, id).await?;
for binding in bindings {
let target_ips = service_binding_ips::list_for_binding(pool, binding.id).await?;
for ip in &target_ips {
if !ips.contains(ip) {
return Err(AppError::Validation(format!(
"IP {} привязан к {}, но отсутствует в новом пуле адресов",
ip,
fqdn_to_display(&binding.hostname, &binding.zone_name)
)));
}
}
}
}
let service = service_repo::get(pool, id).await?;
if should_push_dns_to_cloudflare(pool, &service).await? {
sync_service_bindings_to_dns(pool, cf, id).await?;
sync_group_domain_for_service(pool, cf, id).await?;
}
get_view(pool, id).await
}
async fn normalize_group_domain(
pool: &SqlitePool,
cf: &CloudflareClient,
domain: Option<&str>,
) -> AppResult<Option<String>> {
let Some(raw) = domain.map(str::trim).filter(|d| !d.is_empty()) else {
return Ok(None);
};
let known_zones = collect_known_zones(pool, cf).await?;
let (zone_name, hostname) = parse_fqdn(raw, &known_zones)?;
Ok(Some(fqdn_to_display(&hostname, &zone_name)))
}
async fn cleanup_stale_group_fqdn_bindings(
pool: &SqlitePool,
cf: &CloudflareClient,
group_id: i64,
fqdn: &str,
) -> AppResult<()> {
let known_zones = collect_known_zones(pool, cf).await?;
let (zone_name, hostname) = parse_fqdn(fqdn, &known_zones)?;
if hostname == "@" {
return Ok(());
}
let Some(domain) = domains::find_by_zone_name(pool, &zone_name).await? else {
return Ok(());
};
let services = service_repo::list_by_group(pool, group_id).await?;
for service in services {
let Some(binding) = service_bindings::find_for_service_domain_hostname(
pool,
service.id,
domain.id,
&hostname,
)
.await?
else {
continue;
};
cleanup_binding_dns(pool, cf, binding.id, binding.domain_id, &binding.hostname).await?;
service_bindings::delete(pool, binding.id).await?;
}
Ok(())
}
pub async fn create_group(
pool: &SqlitePool,
cf: &CloudflareClient,
body: &ServiceGroupBody,
) -> AppResult<crate::domain::ServiceGroup> {
let group_type = body
.group_type
.as_deref()
.filter(|t| !t.is_empty())
.unwrap_or("custom");
let domain = normalize_group_domain(pool, cf, body.domain.as_deref()).await?;
service_groups::create(
pool,
&body.name,
group_type,
body.icon.as_deref(),
domain.as_deref(),
)
.await
}
pub async fn update_group(
pool: &SqlitePool,
cf: &CloudflareClient,
id: i64,
body: &ServiceGroupBody,
) -> AppResult<crate::domain::ServiceGroup> {
let group_type = body
.group_type
.as_deref()
.filter(|t| !t.is_empty())
.unwrap_or("custom");
let previous = service_groups::get(pool, id).await?;
if let Some(old_domain) = previous.domain.as_deref().map(str::trim).filter(|d| !d.is_empty()) {
cleanup_stale_group_fqdn_bindings(pool, cf, id, old_domain).await?;
cleanup_group_domain_dns(pool, cf, id).await?;
}
let domain = normalize_group_domain(pool, cf, body.domain.as_deref()).await?;
let group = service_groups::update(
pool,
id,
&body.name,
group_type,
body.icon.as_deref(),
domain.as_deref(),
)
.await?;
sync_enabled_services_in_group(pool, cf, id).await?;
Ok(group)
}
pub async fn delete_group(pool: &SqlitePool, id: i64) -> AppResult<()> {
service_groups::delete(pool, id).await
}
pub async fn toggle_service(
pool: &SqlitePool,
cf: &CloudflareClient,
service_id: i64,
enabled: bool,
) -> AppResult<ServiceView> {
let service = service_repo::get(pool, service_id).await?;
if enabled {
if let Some(group_id) = service.service_group_id {
let group = service_groups::get(pool, group_id).await?;
if !group.enabled {
return Err(AppError::Validation(
"сначала включите группу сервисов".into(),
));
}
if group
.domain
.as_deref()
.map(str::trim)
.filter(|d| !d.is_empty())
.is_none()
{
return Err(AppError::Validation(
"укажите домен у группы сервисов".into(),
));
}
}
}
service_repo::set_enabled(pool, service_id, enabled).await?;
if !enabled {
cleanup_service_dns_only(pool, cf, service_id).await?;
sync_group_domain_for_service(pool, cf, service_id).await?;
return get_view(pool, service_id).await;
}
sync_service_bindings_to_dns(pool, cf, service_id).await?;
sync_group_domain_for_service(pool, cf, service_id).await?;
get_view(pool, service_id).await
}
pub async fn toggle_group(
pool: &SqlitePool,
cf: &CloudflareClient,
group_id: i64,
enabled: bool,
) -> AppResult<ServiceGroupsResponse> {
service_groups::set_enabled(pool, group_id, enabled).await?;
if !enabled {
let services = service_repo::list_by_group(pool, group_id).await?;
for service in services {
if service.enabled {
service_repo::set_enabled(pool, service.id, false).await?;
cleanup_service_dns_only(pool, cf, service.id).await?;
}
}
cleanup_group_domain_dns(pool, cf, group_id).await?;
} else {
sync_enabled_services_in_group(pool, cf, group_id).await?;
}
list_group_views(pool).await
}
+1
View File
@@ -0,0 +1 @@
{"rustc_fingerprint":10405975024632683256,"outputs":{"5414958869613609987":{"success":true,"status":"","code":0,"stdout":"rustc 1.96.0 (ac68faa20 2026-05-25)\nbinary: rustc\ncommit-hash: ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96\ncommit-date: 2026-05-25\nhost: x86_64-pc-windows-msvc\nrelease: 1.96.0\nLLVM version: 22.1.2\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\shats\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""}},"successes":{}}
+3
View File
@@ -0,0 +1,3 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by cargo.
# For information about cache directory tags see https://bford.info/cachedir/
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[\"alloc\"]","declared_features":"[\"alloc\", \"default\", \"fresh-rust\", \"nightly\", \"serde\", \"std\"]","target":5388200169723499962,"profile":12994027242049262075,"path":858156221311629571,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\allocator-api2-2a8f64255b7efe9c\\dep-lib-allocator_api2","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
e831c6c5ed209b7e
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[]","declared_features":"[\"portable-atomic\"]","target":14411119108718288063,"profile":15657897354478470176,"path":15437344850296186914,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\atomic-waker-a63bfe5478bbd105\\dep-lib-atomic_waker","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
b9f33158d267c092
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[]","declared_features":"[]","target":6962977057026645649,"profile":2225463790103693989,"path":556384679085276519,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\autocfg-8c965f4063c10cfd\\dep-lib-autocfg","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[\"aws-lc-sys\", \"prebuilt-nasm\"]","declared_features":"[\"alloc\", \"asan\", \"aws-lc-sys\", \"bindgen\", \"default\", \"dev-tests-only\", \"fips\", \"legacy-des\", \"non-fips\", \"prebuilt-nasm\", \"ring-io\", \"ring-sig-verify\", \"test_logging\", \"unstable\"]","target":5408242616063297496,"profile":2225463790103693989,"path":17378911581759362327,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\aws-lc-rs-314c0c119bd5fad7\\dep-build-script-build-script-build","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[\"prebuilt-nasm\"]","declared_features":"[\"all-bindings\", \"asan\", \"bindgen\", \"default\", \"disable-prebuilt-nasm\", \"fips\", \"prebuilt-nasm\", \"ssl\"]","target":10419965325687163515,"profile":2225463790103693989,"path":6516759104092648623,"deps":[[6778462791484060249,"cmake",false,8499549698829582152],[10941422031512991391,"cc",false,1822230614944177140],[11989259058781683633,"dunce",false,14718673328393535644],[13866570822711233627,"fs_extra",false,8310801538502535119]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\aws-lc-sys-241b387d6b60cf96\\dep-build-script-build-script-main","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
33c1dd5a2011c388
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[\"alloc\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"std\"]","target":13060062996227388079,"profile":2225463790103693989,"path":5400602453988895566,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\base64-3192b3561e34161f\\dep-lib-base64","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
ac5b72562c5fd97f
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"std\"]","target":13060062996227388079,"profile":15657897354478470176,"path":5400602453988895566,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\base64-ca72789b075579c1\\dep-lib-base64","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
34b884917b38f88d
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[]","declared_features":"[]","target":4098124618827574291,"profile":15657897354478470176,"path":6693771042739296001,"deps":[[10520923840501062997,"generic_array",false,1146696233455483665]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\block-buffer-79c3cbccb2d3574f\\dep-lib-block_buffer","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
6d04ddceccb2cde3
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"extra-platforms\", \"serde\", \"std\"]","target":11402411492164584411,"profile":5585765287293540646,"path":6474649128878754451,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\bytes-2374475d4ec174da\\dep-lib-bytes","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
f4db96f616dd4919
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[\"parallel\"]","declared_features":"[\"jobserver\", \"parallel\"]","target":11042037588551934598,"profile":4333757155065362140,"path":9146646945818721565,"deps":[[9159843920629750842,"find_msvc_tools",false,1898077288473079768],[12678166843757613889,"shlex",false,12920360296390691516],[16589527331085190088,"jobserver",false,14657419342798237281]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\cc-768aef08dd0f9d30\\dep-lib-cc","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
2cd3fb7b0f6980b5
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[]","declared_features":"[\"core\", \"rustc-dep-of-std\"]","target":13840298032947503755,"profile":15657897354478470176,"path":9313841782024363090,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\cfg-if-50da2642d7d10359\\dep-lib-cfg_if","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
6fd7a02770ff0d21
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[\"alloc\", \"clock\", \"iana-time-zone\", \"now\", \"std\", \"winapi\", \"windows-link\"]","declared_features":"[\"__internal_bench\", \"alloc\", \"arbitrary\", \"clock\", \"core-error\", \"default\", \"defmt\", \"iana-time-zone\", \"js-sys\", \"libc\", \"now\", \"oldtime\", \"pure-rust-locales\", \"rkyv\", \"rkyv-16\", \"rkyv-32\", \"rkyv-64\", \"rkyv-validation\", \"serde\", \"std\", \"unstable-locales\", \"wasm-bindgen\", \"wasmbind\", \"winapi\", \"windows-link\"]","target":15315924755136109342,"profile":2225463790103693989,"path":603728486674853559,"deps":[[5157631553186200874,"num_traits",false,11933587593128332154],[6959378045035346538,"windows_link",false,15251307201754467492]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\chrono-d67f72f8eeb6b168\\dep-lib-chrono","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
48ff83256b77f475
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[]","declared_features":"[]","target":7530650721721229426,"profile":2225463790103693989,"path":6606306489662641573,"deps":[[10941422031512991391,"cc",false,1822230614944177140]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\cmake-7ca4466e63a0ceeb\\dep-lib-cmake","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":1562763049001146449,"features":"[\"std\"]","declared_features":"[\"default\", \"loom\", \"portable-atomic\", \"std\"]","target":13225166943538818286,"profile":15657897354478470176,"path":5091892759674352740,"deps":[[4468123440088164316,"crossbeam_utils",false,10899212479415549947]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\concurrent-queue-c441731d28dd1257\\dep-lib-concurrent_queue","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
Loaded 100 of 1321 files, more files were not shown because too many files have changed in this diff. Show more