Enhance service management by adding service groups functionality; introduce new schemas for service groups and views; update API routes and handlers for service groups; implement service group creation and update logic; refactor service queries to support grouping; add new dependencies in pnpm-lock.yaml for improved UI components.
Build, Test, and Push CFDM Docker Image / test (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / test (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
interface ServiceBindingIpInputProps {
|
||||
id?: string
|
||||
value: string[]
|
||||
pool: string[]
|
||||
onChange: (value: string[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function ServiceBindingIpInput({
|
||||
id,
|
||||
value,
|
||||
pool,
|
||||
onChange,
|
||||
disabled,
|
||||
}: ServiceBindingIpInputProps) {
|
||||
const available = pool.filter((ip) => !value.includes(ip))
|
||||
const isPoolEmpty = pool.length === 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<TaggedInput
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={
|
||||
isPoolEmpty ? 'Сначала добавьте IP в пул сервиса' : 'Выберите IP из пула'
|
||||
}
|
||||
validate={(ip) => isValidIpv4(ip) && pool.includes(ip)}
|
||||
disabled={disabled || isPoolEmpty}
|
||||
/>
|
||||
{available.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{available.map((ip) => (
|
||||
<Button
|
||||
key={ip}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange([...value, ip])}
|
||||
>
|
||||
+ {ip}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { PencilIcon } from 'lucide-react'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { bindingToFqdn } from '@/lib/parse-fqdn'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
|
||||
interface ServiceCardProps {
|
||||
service: ServiceView
|
||||
onEdit: (service: ServiceView) => void
|
||||
}
|
||||
|
||||
function aggregateSyncStatus(service: ServiceView) {
|
||||
const statuses = (service.domains ?? [])
|
||||
.map((domain) => domain.sync_status)
|
||||
.filter((status): status is string => Boolean(status))
|
||||
if (statuses.length === 0) return null
|
||||
if (statuses.includes('error')) return 'error'
|
||||
if (statuses.includes('pending_push')) return 'pending_push'
|
||||
if (statuses.every((status) => status === 'synced')) return 'synced'
|
||||
return statuses[0]
|
||||
}
|
||||
|
||||
export function ServiceCard({ service, onEdit }: ServiceCardProps) {
|
||||
const ips = service.ips ?? []
|
||||
const domains = service.domains ?? []
|
||||
const syncStatus = aggregateSyncStatus(service)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{service.name}</CardTitle>
|
||||
<CardDescription>
|
||||
<Badge variant="outline">{service.slug}</Badge>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">IP-адреса</p>
|
||||
{ips.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">—</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ips.map((ip) => (
|
||||
<Badge key={ip} variant="secondary">
|
||||
{ip}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">Домены</p>
|
||||
{domains.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Не привязаны</p>
|
||||
) : (
|
||||
<ItemGroup>
|
||||
{domains.map((binding) => (
|
||||
<Item key={binding.binding_id} variant="outline" size="sm">
|
||||
<ItemContent>
|
||||
<ItemTitle className="flex flex-wrap items-center gap-2">
|
||||
<span>{bindingToFqdn(binding)}</span>
|
||||
{binding.target_ips.map((ip) => (
|
||||
<Badge key={ip} variant="secondary">
|
||||
{ip}
|
||||
</Badge>
|
||||
))}
|
||||
{binding.sync_status ? (
|
||||
<StatusBadge status={binding.sync_status} />
|
||||
) : null}
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>{syncStatus ? <StatusBadge status={syncStatus} /> : null}</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onEdit(service)}>
|
||||
<PencilIcon data-icon="inline-start" />
|
||||
Редактировать
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { PlusIcon, Trash2Icon } from 'lucide-react'
|
||||
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
|
||||
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
|
||||
import type {
|
||||
CreateServiceWithConfigInput,
|
||||
DomainListItem,
|
||||
ServiceGroupView,
|
||||
ServiceView,
|
||||
UpdateServiceConfigInput,
|
||||
} from '@/lib/schemas'
|
||||
import { bindingToFqdn } from '@/lib/parse-fqdn'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
} from '@cfdm/ui/components/item'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
|
||||
export interface ServiceBindingDraft {
|
||||
fqdn: string
|
||||
target_ips: string[]
|
||||
}
|
||||
|
||||
interface ServiceEditSheetProps {
|
||||
mode: 'create' | 'edit'
|
||||
service: ServiceView | null
|
||||
groups: ServiceGroupView[]
|
||||
open: boolean
|
||||
knownDomains: DomainListItem[]
|
||||
isSaving: boolean
|
||||
isDeleting?: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreate?: (body: CreateServiceWithConfigInput) => void
|
||||
onSave?: (id: number, body: UpdateServiceConfigInput) => void
|
||||
onDelete?: (id: number) => void
|
||||
}
|
||||
|
||||
function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
||||
return (service.domains ?? []).map((binding) => ({
|
||||
fqdn: bindingToFqdn(binding),
|
||||
target_ips: binding.target_ips ?? [],
|
||||
}))
|
||||
}
|
||||
|
||||
function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
||||
return bindings
|
||||
.filter((binding) => binding.fqdn.trim() && binding.target_ips.length > 0)
|
||||
.map((binding) => ({
|
||||
fqdn: binding.fqdn.trim(),
|
||||
target_ips: binding.target_ips,
|
||||
}))
|
||||
}
|
||||
|
||||
export function ServiceEditSheet({
|
||||
mode,
|
||||
service,
|
||||
groups,
|
||||
open,
|
||||
knownDomains,
|
||||
isSaving,
|
||||
isDeleting = false,
|
||||
onOpenChange,
|
||||
onCreate,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: ServiceEditSheetProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
const [serviceGroupId, setServiceGroupId] = useState('none')
|
||||
const [ips, setIps] = useState<string[]>([])
|
||||
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
|
||||
|
||||
const groupItems = useMemo(
|
||||
() => [
|
||||
{ label: 'Без группы', value: 'none' },
|
||||
...groups.map((group) => ({ label: group.name, value: String(group.id) })),
|
||||
],
|
||||
[groups],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (mode === 'edit' && service) {
|
||||
setName(service.name)
|
||||
setSlug(service.slug)
|
||||
setServiceGroupId(
|
||||
service.service_group_id != null ? String(service.service_group_id) : 'none',
|
||||
)
|
||||
setIps(service.ips ?? [])
|
||||
setBindings(toBindingDrafts(service))
|
||||
return
|
||||
}
|
||||
if (mode === 'create') {
|
||||
setName('')
|
||||
setSlug('')
|
||||
setServiceGroupId('none')
|
||||
setIps([])
|
||||
setBindings([])
|
||||
}
|
||||
}, [open, mode, service])
|
||||
|
||||
const zoneHints = useMemo(
|
||||
() => knownDomains.map((domain) => domain.zone_name),
|
||||
[knownDomains],
|
||||
)
|
||||
|
||||
function handleAddBinding() {
|
||||
setBindings((current) => [...current, { fqdn: '', target_ips: [] }])
|
||||
}
|
||||
|
||||
function handleRemoveBinding(index: number) {
|
||||
setBindings((current) => current.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function handleFqdnChange(index: number, tags: string[]) {
|
||||
const fqdn = tags[0] ?? ''
|
||||
setBindings((current) =>
|
||||
current.map((item, i) => (i === index ? { ...item, fqdn } : item)),
|
||||
)
|
||||
}
|
||||
|
||||
function handleIpsChange(index: number, targetIps: string[]) {
|
||||
setBindings((current) =>
|
||||
current.map((item, i) => (i === index ? { ...item, target_ips: targetIps } : item)),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveServiceGroupId(): number | null {
|
||||
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const domains = buildDomainsPayload(bindings)
|
||||
const groupId = resolveServiceGroupId()
|
||||
const configPayload = {
|
||||
ips,
|
||||
...(domains.length > 0 ? { domains } : {}),
|
||||
}
|
||||
if (mode === 'create') {
|
||||
onCreate?.({
|
||||
name: name.trim(),
|
||||
slug: slug.trim(),
|
||||
service_group_id: groupId,
|
||||
...configPayload,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!service) return
|
||||
onSave?.(service.id, {
|
||||
name,
|
||||
slug,
|
||||
service_group_id: groupId,
|
||||
...configPayload,
|
||||
})
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!service) return
|
||||
onDelete?.(service.id)
|
||||
}
|
||||
|
||||
const isCreate = mode === 'create'
|
||||
const canSubmit = isCreate
|
||||
? name.trim().length > 0 && slug.trim().length > 0
|
||||
: Boolean(service)
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="overflow-y-auto sm:max-w-lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Настройте IP-пул и привязки FQDN → IP. Зона определяется из FQDN автоматически.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-4 px-4">
|
||||
<FieldGroup className="flex flex-col gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
|
||||
<Input
|
||||
id="edit-service-name"
|
||||
value={name}
|
||||
placeholder={isCreate ? 'VPN Panel' : undefined}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-slug">Slug</FieldLabel>
|
||||
<Input
|
||||
id="edit-service-slug"
|
||||
value={slug}
|
||||
placeholder={isCreate ? 'vpn-panel' : undefined}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
|
||||
<Select
|
||||
items={groupItems}
|
||||
value={serviceGroupId}
|
||||
onValueChange={(value) => setServiceGroupId(value ?? 'none')}
|
||||
>
|
||||
<SelectTrigger id="edit-service-group" className="w-full">
|
||||
<SelectValue placeholder="Без группы" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
|
||||
<TaggedInput
|
||||
id="edit-service-ips"
|
||||
value={ips}
|
||||
onChange={setIps}
|
||||
placeholder="192.168.1.1"
|
||||
validate={isValidIpv4}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FieldLabel>Привязки доменов</FieldLabel>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
{bindings.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Необязательно. Введите FQDN, например newdom.ivx.su — зона ivx.su определится
|
||||
автоматически.
|
||||
</p>
|
||||
) : (
|
||||
<ItemGroup>
|
||||
{bindings.map((binding, index) => (
|
||||
<Item key={`binding-${index}`} variant="outline">
|
||||
<ItemContent className="flex flex-col gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
|
||||
<TaggedInput
|
||||
id={`binding-fqdn-${index}`}
|
||||
value={binding.fqdn ? [binding.fqdn] : []}
|
||||
onChange={(tags) => handleFqdnChange(index, tags)}
|
||||
placeholder={zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'}
|
||||
maxItems={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
|
||||
<ServiceBindingIpInput
|
||||
id={`binding-ip-${index}`}
|
||||
value={binding.target_ips}
|
||||
pool={ips}
|
||||
onChange={(targetIps) => handleIpsChange(index, targetIps)}
|
||||
/>
|
||||
</Field>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Удалить привязку"
|
||||
onClick={() => handleRemoveBinding(index)}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SheetFooter className="flex flex-row flex-wrap gap-2">
|
||||
{!isCreate ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={!service || isDeleting || isSaving}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{isDeleting && <Spinner data-icon="inline-start" />}
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
Удалить
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
className={isCreate ? 'ml-auto' : 'ml-auto'}
|
||||
disabled={!canSubmit || isSaving || isDeleting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSaving && <Spinner data-icon="inline-start" />}
|
||||
{isCreate ? 'Создать' : 'Сохранить'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { ChevronDownIcon, PencilIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { ServiceGroupIcon } from '@/components/service-group-icon'
|
||||
import { ServiceRow } from '@/components/service-row'
|
||||
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@cfdm/ui/components/collapsible'
|
||||
import { ItemGroup } from '@cfdm/ui/components/item'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface ServiceGroupCardProps {
|
||||
group: ServiceGroupView
|
||||
onGroupToggle: (groupId: number, enabled: boolean) => void
|
||||
onServiceToggle: (serviceId: number, enabled: boolean) => void
|
||||
onEditService: (service: ServiceView) => void
|
||||
onEditGroup: (group: ServiceGroupView) => void
|
||||
togglingGroupId?: number | null
|
||||
togglingServiceId?: number | null
|
||||
}
|
||||
|
||||
export function ServiceGroupCard({
|
||||
group,
|
||||
onGroupToggle,
|
||||
onServiceToggle,
|
||||
onEditService,
|
||||
onEditGroup,
|
||||
togglingGroupId = null,
|
||||
togglingServiceId = null,
|
||||
}: ServiceGroupCardProps) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const isGroupToggling = togglingGroupId === group.id
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'flex flex-1 items-center gap-2 text-left',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'size-4 shrink-0 transition-transform',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
<ServiceGroupIcon type={group.type} />
|
||||
<CardTitle className="flex-1">{group.name}</CardTitle>
|
||||
</CollapsibleTrigger>
|
||||
{group.domain ? (
|
||||
<Badge variant="outline">{group.domain}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<CardAction className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => onEditGroup(group)}
|
||||
aria-label={`Редактировать группу ${group.name}`}
|
||||
>
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
{isGroupToggling ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={group.enabled}
|
||||
disabled={isGroupToggling}
|
||||
onCheckedChange={(checked) => onGroupToggle(group.id, checked)}
|
||||
aria-label={`${group.enabled ? 'Выключить' : 'Включить'} группу ${group.name}`}
|
||||
/>
|
||||
)}
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CollapsibleContent>
|
||||
<CardContent>
|
||||
{group.services.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Нет сервисов в группе</p>
|
||||
) : (
|
||||
<ItemGroup>
|
||||
{group.services.map((service) => (
|
||||
<ServiceRow
|
||||
key={service.id}
|
||||
service={service}
|
||||
onToggle={onServiceToggle}
|
||||
onEdit={onEditService}
|
||||
isToggling={togglingServiceId === service.id}
|
||||
disabled={!group.enabled}
|
||||
/>
|
||||
))}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { CreateServiceGroupInput, ServiceGroup } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
const groupTypes = [
|
||||
{ value: 'vpn', label: 'VPN' },
|
||||
{ value: 'network', label: 'Сеть' },
|
||||
{ value: 'internet', label: 'Интернет' },
|
||||
{ value: 'bgp', label: 'BGP' },
|
||||
{ value: 'custom', label: 'Другое' },
|
||||
] as const
|
||||
|
||||
interface ServiceGroupEditSheetProps {
|
||||
mode: 'create' | 'edit'
|
||||
group: ServiceGroup | null
|
||||
open: boolean
|
||||
isSaving: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreate?: (body: CreateServiceGroupInput) => void
|
||||
onSave?: (id: number, body: CreateServiceGroupInput) => void
|
||||
}
|
||||
|
||||
export function ServiceGroupEditSheet({
|
||||
mode,
|
||||
group,
|
||||
open,
|
||||
isSaving,
|
||||
onOpenChange,
|
||||
onCreate,
|
||||
onSave,
|
||||
}: ServiceGroupEditSheetProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [type, setType] = useState<CreateServiceGroupInput['type']>('custom')
|
||||
const [domain, setDomain] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (mode === 'edit' && group) {
|
||||
setName(group.name)
|
||||
setType(group.type)
|
||||
setDomain(group.domain ?? '')
|
||||
} else {
|
||||
setName('')
|
||||
setType('custom')
|
||||
setDomain('')
|
||||
}
|
||||
}, [open, mode, group])
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) return
|
||||
const body: CreateServiceGroupInput = {
|
||||
name: trimmedName,
|
||||
type,
|
||||
domain: domain.trim() || null,
|
||||
}
|
||||
if (mode === 'create') {
|
||||
onCreate?.(body)
|
||||
} else if (group) {
|
||||
onSave?.(group.id, body)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Домен группы — любой FQDN (gr.ivx.su, domain.new.ivx.su). Публикуется в Cloudflare отдельно от привязок сервисов.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6 px-4">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="group-name">Название</FieldLabel>
|
||||
<Input
|
||||
id="group-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="VPN"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="group-type">Тип</FieldLabel>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(value) =>
|
||||
setType(value as CreateServiceGroupInput['type'])
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="group-type">
|
||||
<SelectValue placeholder="Выберите тип" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupTypes.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="group-domain">Домен группы (FQDN)</FieldLabel>
|
||||
<Input
|
||||
id="group-domain"
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
placeholder="domain.new.ivx.su"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<SheetFooter>
|
||||
<Button type="submit" disabled={isSaving} className="w-full">
|
||||
{isSaving && <Spinner data-icon="inline-start" />}
|
||||
{isSaving ? 'Сохранение…' : mode === 'create' ? 'Создать' : 'Сохранить'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
GlobeIcon,
|
||||
NetworkIcon,
|
||||
RouterIcon,
|
||||
ServerIcon,
|
||||
ShieldIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type { ServiceGroup } from '@/lib/schemas'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
const typeIcons: Record<ServiceGroup['type'], LucideIcon> = {
|
||||
vpn: ShieldIcon,
|
||||
network: NetworkIcon,
|
||||
internet: GlobeIcon,
|
||||
bgp: RouterIcon,
|
||||
custom: ServerIcon,
|
||||
}
|
||||
|
||||
interface ServiceGroupIconProps {
|
||||
type: ServiceGroup['type']
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ServiceGroupIcon({ type, className }: ServiceGroupIconProps) {
|
||||
const Icon = typeIcons[type] ?? ServerIcon
|
||||
return <Icon className={cn('size-4 shrink-0', className)} />
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from '@cfdm/ui/components/combobox'
|
||||
|
||||
interface ServiceIpComboboxProps {
|
||||
id?: string
|
||||
value: string
|
||||
items: string[]
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function ServiceIpCombobox({
|
||||
id,
|
||||
value,
|
||||
items,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = 'Выберите IP',
|
||||
}: ServiceIpComboboxProps) {
|
||||
const isDisabled = disabled || items.length === 0
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
items={items}
|
||||
value={value}
|
||||
onValueChange={(next) => onChange(next ?? '')}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<ComboboxInput
|
||||
id={id}
|
||||
className="w-full"
|
||||
placeholder={items.length === 0 ? 'Сначала добавьте IP' : placeholder}
|
||||
showClear={Boolean(value)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>Нет совпадений</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item: string) => (
|
||||
<ComboboxItem key={item} value={item}>
|
||||
{item}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { PencilIcon } from 'lucide-react'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
aggregateServiceSyncStatus,
|
||||
serviceDisplayFqdn,
|
||||
} from '@/lib/service-utils'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
|
||||
interface ServiceRowProps {
|
||||
service: ServiceView
|
||||
onToggle: (serviceId: number, enabled: boolean) => void
|
||||
onEdit: (service: ServiceView) => void
|
||||
isToggling?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function ServiceRow({
|
||||
service,
|
||||
onToggle,
|
||||
onEdit,
|
||||
isToggling = false,
|
||||
disabled = false,
|
||||
}: ServiceRowProps) {
|
||||
const syncStatus = aggregateServiceSyncStatus(service)
|
||||
const fqdn = serviceDisplayFqdn(service)
|
||||
|
||||
return (
|
||||
<Item variant="outline" size="sm">
|
||||
<ItemContent>
|
||||
<ItemTitle>{service.name}</ItemTitle>
|
||||
<ItemDescription>{fqdn}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions className="flex flex-wrap items-center gap-2">
|
||||
{syncStatus ? <StatusBadge status={syncStatus} /> : null}
|
||||
{isToggling ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={service.enabled}
|
||||
disabled={disabled || isToggling}
|
||||
onCheckedChange={(checked) => onToggle(service.id, checked)}
|
||||
aria-label={`${service.enabled ? 'Выключить' : 'Включить'} ${service.name}`}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(service)}
|
||||
>
|
||||
<PencilIcon data-icon="inline-start" />
|
||||
Редактировать
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@cfdm/ui/components/input-group'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface TaggedInputProps {
|
||||
id?: string
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
placeholder?: string
|
||||
validate?: (value: string) => boolean
|
||||
maxItems?: number
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
'aria-invalid'?: boolean
|
||||
}
|
||||
|
||||
function normalizeTag(raw: string) {
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
export function TaggedInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
validate,
|
||||
maxItems,
|
||||
disabled,
|
||||
className,
|
||||
'aria-invalid': ariaInvalid,
|
||||
}: TaggedInputProps) {
|
||||
const [pending, setPending] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending.includes(',')) return
|
||||
const chunks = pending
|
||||
.split(',')
|
||||
.map(normalizeTag)
|
||||
.filter(Boolean)
|
||||
.filter((chunk) => !validate || validate(chunk))
|
||||
if (chunks.length === 0) {
|
||||
setPending('')
|
||||
return
|
||||
}
|
||||
const next = new Set(maxItems === 1 ? chunks.slice(-1) : [...value, ...chunks])
|
||||
onChange(Array.from(next))
|
||||
setPending('')
|
||||
}, [pending, onChange, validate, value, maxItems])
|
||||
|
||||
function addPending() {
|
||||
const tag = normalizeTag(pending)
|
||||
if (!tag) return
|
||||
if (validate && !validate(tag)) return
|
||||
if (value.includes(tag)) {
|
||||
setPending('')
|
||||
return
|
||||
}
|
||||
const next = maxItems === 1 ? [tag] : [...value, tag]
|
||||
onChange(next)
|
||||
setPending('')
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
onChange(value.filter((item) => item !== tag))
|
||||
}
|
||||
|
||||
return (
|
||||
<InputGroup
|
||||
className={cn(
|
||||
'h-auto min-h-8 flex-wrap items-center gap-1.5 py-1.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{value.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="gap-1 pr-1">
|
||||
{tag}
|
||||
<InputGroupButton
|
||||
type="button"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
aria-label={`Удалить ${tag}`}
|
||||
onClick={() => removeTag(tag)}
|
||||
>
|
||||
<XIcon />
|
||||
</InputGroupButton>
|
||||
</Badge>
|
||||
))}
|
||||
<InputGroupInput
|
||||
id={id}
|
||||
value={pending}
|
||||
disabled={disabled}
|
||||
placeholder={value.length === 0 ? placeholder : undefined}
|
||||
aria-invalid={ariaInvalid}
|
||||
className="min-w-24 flex-1"
|
||||
onChange={(e) => setPending(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault()
|
||||
addPending()
|
||||
} else if (
|
||||
e.key === 'Backspace' &&
|
||||
pending.length === 0 &&
|
||||
value.length > 0
|
||||
) {
|
||||
e.preventDefault()
|
||||
onChange(value.slice(0, -1))
|
||||
}
|
||||
}}
|
||||
onBlur={addPending}
|
||||
/>
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export const IPV4_REGEX =
|
||||
/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/
|
||||
|
||||
export function isValidIpv4(value: string) {
|
||||
return IPV4_REGEX.test(value)
|
||||
}
|
||||
Reference in New Issue
Block a user