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

This commit is contained in:
Denozordec
2026-06-19 11:19:22 +07:00
parent 5f7b3ac388
commit 0a0a1a92f1
1321 changed files with 10817 additions and 317 deletions
@@ -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)
}