Compare commits

...
1 Commits
Author SHA1 Message Date
Denozordec 50c5c21c18 feat(services): enhance service edit and display components with common domain support
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 5s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 52s
CD / quality (push) Successful in 1m1s
CD / publish (push) Successful in 1m36s
- Added a common FQDN input field in the ServiceEditSheet to streamline domain management for services.
- Updated binding handling to synchronize the common domain across service bindings.
- Improved the ServiceKanbanCard to display both common domain and IP addresses for better visibility.
- Refactored the ServiceFqdnList to support customizable text classes and added a new ServiceIpList component for IP display.

This commit enhances the user experience by simplifying domain management and improving the display of service information.
2026-08-19 13:29:15 +07:00
9 changed files with 511 additions and 538 deletions
@@ -3,7 +3,7 @@ import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import { ServiceFqdnList } from '@/components/services/service-fqdn-list'
import { ServiceFqdnList, ServiceIpList } from '@/components/services/service-fqdn-list'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
@@ -78,7 +78,18 @@ export function ServiceKanbanCard({
</ItemHeader>
<ItemContent className="min-w-0 gap-2">
<ServiceFqdnList copyable service={service} />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-muted-foreground text-xs">Общий домен</span>
<ServiceFqdnList
copyable
service={service}
emptyLabel="Не задан"
/>
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-muted-foreground text-xs">IP</span>
<ServiceIpList copyable ips={service.ips ?? []} />
</div>
</ItemContent>
<ItemFooter className="min-w-0 justify-between gap-2">
+94 -71
View File
@@ -46,7 +46,6 @@ import {
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Separator } from '@cfdm/ui/components/separator'
interface BindingHealthConfig {
enabled: boolean
@@ -179,6 +178,7 @@ export function ServiceEditSheet({
const [slug, setSlug] = useState('')
const [serviceGroupId, setServiceGroupId] = useState('none')
const [ips, setIps] = useState<string[]>([])
const [commonFqdn, setCommonFqdn] = useState('')
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
const [lbWeight, setLbWeight] = useState(1)
const [lbPriority, setLbPriority] = useState(1)
@@ -192,13 +192,6 @@ export function ServiceEditSheet({
[groups],
)
const selectedGroup = useMemo(() => {
if (serviceGroupId === 'none') return null
return groups.find((g) => String(g.id) === serviceGroupId) ?? null
}, [groups, serviceGroupId])
const groupHasDomain = Boolean(selectedGroup?.domain?.trim())
useEffect(() => {
if (!open) return
setActiveTab('general')
@@ -209,7 +202,9 @@ export function ServiceEditSheet({
service.service_group_id != null ? String(service.service_group_id) : 'none',
)
setIps(service.ips ?? [])
setBindings(toBindingDrafts(service))
const drafts = toBindingDrafts(service)
setBindings(drafts)
setCommonFqdn(drafts[0]?.fqdn ?? '')
setLbWeight(service.lb_weight ?? 1)
setLbPriority(service.lb_priority ?? 1)
return
@@ -221,6 +216,7 @@ export function ServiceEditSheet({
defaultGroupId != null ? String(defaultGroupId) : 'none',
)
setIps([])
setCommonFqdn('')
setBindings([])
setLbWeight(1)
setLbPriority(1)
@@ -232,27 +228,46 @@ export function ServiceEditSheet({
[knownDomains],
)
function emptyBindingDraft(fqdn = ''): ServiceBindingDraft {
return {
fqdn,
record_type: 'A',
target_ips: [],
target_cname: '',
lb_mode: 'round_robin',
health: { ...defaultHealth },
target_ip_weights: {},
target_ip_priorities: {},
}
}
function handleAddBinding() {
setBindings((current) => [
...current,
{
fqdn: '',
record_type: 'A',
target_ips: [],
target_cname: '',
lb_mode: 'round_robin',
health: { ...defaultHealth },
target_ip_weights: {},
target_ip_priorities: {},
},
emptyBindingDraft(current.length === 0 ? commonFqdn : ''),
])
}
function handleRemoveBinding(index: number) {
setBindings((current) => current.filter((_, i) => i !== index))
setBindings((current) => {
const next = current.filter((_, i) => i !== index)
if (index === 0) {
setCommonFqdn(next[0]?.fqdn ?? '')
}
return next
})
}
function handleCommonFqdnChange(value: string) {
setCommonFqdn(value)
setBindings((current) => {
if (current.length === 0) return current
return current.map((item, i) => (i === 0 ? { ...item, fqdn: value } : item))
})
}
function handleFqdnChange(index: number, fqdn: string) {
if (index === 0) setCommonFqdn(fqdn)
setBindings((current) =>
current.map((item, i) => (i === index ? { ...item, fqdn } : item)),
)
@@ -343,8 +358,46 @@ export function ServiceEditSheet({
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
}
function syncCommonDomain(current: ServiceBindingDraft[]): ServiceBindingDraft[] {
const trimmed = commonFqdn.trim()
if (!trimmed) return current
if (current.length === 0) {
const draft = emptyBindingDraft(trimmed)
return [
{
...draft,
target_ips: ips,
target_ip_weights: Object.fromEntries(ips.map((ip) => [ip, 1])),
target_ip_priorities: Object.fromEntries(ips.map((ip) => [ip, 1])),
},
]
}
return current.map((item, index) => {
if (index !== 0) return item
const next = { ...item, fqdn: trimmed }
if (
next.record_type === 'A' &&
next.target_ips.length === 0 &&
ips.length > 0
) {
return {
...next,
target_ips: ips,
target_ip_weights: Object.fromEntries(
ips.map((ip) => [ip, item.target_ip_weights[ip] ?? 1]),
),
target_ip_priorities: Object.fromEntries(
ips.map((ip) => [ip, item.target_ip_priorities[ip] ?? 1]),
),
}
}
return next
})
}
function handleSubmit() {
const domains = buildDomainsPayload(bindings)
const syncedBindings = syncCommonDomain(bindings)
const domains = buildDomainsPayload(syncedBindings)
const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase())
const hasDuplicateFqdn =
new Set(normalizedFqdns).size !== normalizedFqdns.length
@@ -354,13 +407,11 @@ export function ServiceEditSheet({
return
}
const groupId = resolveServiceGroupId()
const lbFields = groupHasDomain
? { lb_weight: lbWeight, lb_priority: lbPriority }
: {}
const configPayload = {
ips,
domains,
...lbFields,
lb_weight: lbWeight,
lb_priority: lbPriority,
}
if (mode === 'create') {
onCreate?.({
@@ -368,7 +419,8 @@ export function ServiceEditSheet({
slug: slug.trim(),
service_group_id: groupId,
ips,
...lbFields,
lb_weight: lbWeight,
lb_priority: lbPriority,
domains,
})
return
@@ -398,9 +450,8 @@ export function ServiceEditSheet({
<SheetHeader className="shrink-0 border-b pb-4">
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
<SheetDescription>
Настройте параметры сервиса и привязки FQDN IP или CNAME. Один
сервис может иметь несколько FQDN в разных зонах; зона определяется
из FQDN автоматически.
Общий домен и IP задаются у сервиса. Дополнительные FQDN на вкладке
привязок; зона определяется из FQDN автоматически.
</SheetDescription>
</SheetHeader>
@@ -458,6 +509,20 @@ export function ServiceEditSheet({
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-common-domain">
Общий домен (FQDN)
</FieldLabel>
<Input
id="edit-service-common-domain"
className="font-mono"
value={commonFqdn}
placeholder={
zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'
}
onChange={(e) => handleCommonFqdnChange(e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
<TaggedInput
@@ -469,48 +534,6 @@ export function ServiceEditSheet({
/>
</Field>
</FieldGroup>
{groupHasDomain && (
<>
<Separator />
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
Балансировка внутри группы «{selectedGroup?.name}»: вес и приоритет
сервиса для общего домена группы.
</p>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor="service-lb-weight">Вес</FieldLabel>
<Input
id="service-lb-weight"
type="number"
inputMode="numeric"
min={1}
max={100}
value={lbWeight}
onChange={(e) =>
setLbWeight(Math.max(1, Number(e.target.value) || 1))
}
/>
</Field>
<Field>
<FieldLabel htmlFor="service-lb-priority">Приоритет</FieldLabel>
<Input
id="service-lb-priority"
type="number"
inputMode="numeric"
min={1}
max={100}
value={lbPriority}
onChange={(e) =>
setLbPriority(Math.max(1, Number(e.target.value) || 1))
}
/>
</Field>
</div>
</div>
</>
)}
</TabsContent>
<TabsContent value="bindings" className="flex flex-col gap-4">
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import {
@@ -12,10 +12,6 @@ import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { AppFieldGroup } from '@/components/app-field'
import { AppInput } from '@/components/app-input'
import {
HealthCheckConfigFields,
type LbAndHealthConfig,
} from '@/components/health-check-config-fields'
import {
Select,
SelectContent,
@@ -44,19 +40,6 @@ interface ServiceGroupEditSheetProps {
onSave?: (id: number, body: CreateServiceGroupInput) => void
}
const defaultLbHealth: LbAndHealthConfig = {
lb_mode: 'round_robin',
enabled: false,
type: 'tcp',
port: null,
path: null,
expected_status: null,
interval_sec: 30,
timeout_ms: 3000,
verify_tls: false,
provider: 'local',
}
export function ServiceGroupEditSheet({
mode,
group,
@@ -74,7 +57,6 @@ export function ServiceGroupEditSheet({
domain: null,
},
})
const [lbHealth, setLbHealth] = useState<LbAndHealthConfig>(defaultLbHealth)
useEffect(() => {
if (!open) return
@@ -82,44 +64,19 @@ export function ServiceGroupEditSheet({
form.reset({
name: group.name,
type: group.type,
domain: group.domain ?? null,
})
setLbHealth({
lb_mode: group.lb_mode,
enabled: group.health_check_enabled,
type: group.health_check_type === 'http' ? 'http' : 'tcp',
port: group.health_check_port,
path: group.health_check_path,
expected_status: group.health_check_expected_status,
interval_sec: group.health_check_interval_sec,
timeout_ms: group.health_check_timeout_ms,
verify_tls: group.health_check_verify_tls,
provider: 'local',
domain: null,
})
} else {
form.reset({ name: '', type: 'custom', domain: null })
setLbHealth(defaultLbHealth)
}
}, [open, mode, group, form])
const domainValue = form.watch('domain')
const hasDomain = Boolean(domainValue?.trim())
function handleSubmit(values: ServiceGroupFormValues) {
const body: CreateServiceGroupInput = {
name: values.name,
type: values.type ?? 'custom',
icon: values.icon,
domain: values.domain?.trim() || null,
lb_mode: lbHealth.lb_mode,
health_check_enabled: lbHealth.enabled,
health_check_type: lbHealth.type,
health_check_port: lbHealth.port,
health_check_path: lbHealth.path,
health_check_expected_status: lbHealth.expected_status,
health_check_interval_sec: lbHealth.interval_sec,
health_check_timeout_ms: lbHealth.timeout_ms,
health_check_verify_tls: lbHealth.verify_tls,
domain: null,
}
if (mode === 'create') {
onCreate?.(body)
@@ -133,7 +90,7 @@ export function ServiceGroupEditSheet({
open={open}
onOpenChange={onOpenChange}
title={mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
description="Домен группы необязателен. Если указан FQDN (gr.ivx.su, domain.new.ivx.su), он публикуется в Cloudflare отдельно от привязок сервисов."
description="Группа нужна только для сортировки каталога. Общий домен и IP задаются у каждого сервиса."
form={form}
onSubmit={handleSubmit}
contentClassName="gap-6"
@@ -186,26 +143,7 @@ export function ServiceGroupEditSheet({
)}
/>
</FormFieldSimple>
<FormFieldSimple
label="Домен группы (FQDN, необязательно)"
htmlFor="group-domain"
>
<AppInput
id="group-domain"
placeholder="domain.new.ivx.su"
{...form.register('domain')}
/>
</FormFieldSimple>
</AppFieldGroup>
{hasDomain ? (
<HealthCheckConfigFields
value={lbHealth}
onChange={setLbHealth}
lbModeLabel="Режим балансировки общего домена"
idPrefix="group-lb-health"
/>
) : null}
</FormSheet>
)
}
@@ -0,0 +1,157 @@
import { FolderOpenIcon, MoreHorizontalIcon, PlusIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import { ServiceGroupIcon } from '@/components/service-group-icon'
import { ServiceUnitCard } from '@/components/services/service-unit-card'
import type { ServiceGroup, ServiceGroupView, ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
const GROUP_TYPE_LABELS: Record<ServiceGroup['type'], string> = {
vpn: 'VPN',
network: 'Сеть',
internet: 'Интернет',
bgp: 'BGP',
custom: 'Другое',
}
interface ServiceCatalogSectionProps {
group: ServiceGroupView | null
services: ServiceView[]
togglingId: number | null
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
onEditGroup: (group: ServiceGroupView) => void
onDeleteGroup: (group: ServiceGroupView) => void
onAddServiceToGroup: (groupId: number | null) => void
}
export function ServiceCatalogSection({
group,
services,
togglingId,
onEditService,
onDeleteService,
onToggleService,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
}: ServiceCatalogSectionProps) {
const title = group?.name ?? 'Без группы'
const typeLabel = group ? GROUP_TYPE_LABELS[group.type] : null
const groupId = group?.id ?? null
return (
<section className="flex w-full flex-col gap-2" aria-labelledby={`group-${groupId ?? 'none'}`}>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<IconTile
variant="elevated"
size="sm"
className="text-muted-foreground"
aria-hidden="true"
>
{group ? (
<ServiceGroupIcon type={group.type} />
) : (
<FolderOpenIcon />
)}
</IconTile>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<h2
id={`group-${groupId ?? 'none'}`}
className="truncate text-sm font-medium"
>
{title}
</h2>
{typeLabel ? (
<span className="text-muted-foreground text-xs">{typeLabel}</span>
) : null}
<Badge variant="outline" size="xs" className="shrink-0 tabular-nums">
{services.length}
</Badge>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`Добавить сервис в ${title}`}
onClick={() => onAddServiceToGroup(groupId)}
>
<PlusIcon aria-hidden />
</Button>
{group ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия группы ${group.name}`}
/>
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onAddServiceToGroup(group.id)}>
<PlusIcon aria-hidden />
Добавить сервис
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditGroup(group)}>
Изменить группу
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteGroup(group)}
>
Удалить группу
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
{services.length === 0 ? (
<div className="flex flex-wrap items-center justify-between gap-2 py-1">
<span className="text-muted-foreground text-sm">Нет сервисов</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onAddServiceToGroup(groupId)}
>
<PlusIcon data-icon="inline-start" aria-hidden />
Добавить сервис
</Button>
</div>
) : (
<div className="flex flex-col gap-2">
{services.map((service) => (
<ServiceUnitCard
key={service.id}
service={service}
togglingId={togglingId}
onEditService={onEditService}
onDeleteService={onDeleteService}
onToggleService={onToggleService}
/>
))}
</div>
)}
</section>
)
}
@@ -53,6 +53,7 @@ interface ServiceFqdnListProps {
className?: string
emptyLabel?: string
copyable?: boolean
textClassName?: string
}
export function ServiceFqdnList({
@@ -60,6 +61,7 @@ export function ServiceFqdnList({
className,
emptyLabel = 'Нет FQDN',
copyable = false,
textClassName,
}: ServiceFqdnListProps) {
const fqdns = serviceDisplayFqdns(service)
if (fqdns.length === 0) {
@@ -76,7 +78,12 @@ export function ServiceFqdnList({
return (
<div className={cn('flex min-w-0 items-center gap-1.5', className)}>
<TruncatedText className="text-muted-foreground min-w-0 font-mono text-xs">
<TruncatedText
className={cn(
'text-muted-foreground min-w-0 font-mono text-xs',
textClassName,
)}
>
{first}
</TruncatedText>
{extraCount > 0 ? (
@@ -107,3 +114,71 @@ export function ServiceFqdnList({
</div>
)
}
const VISIBLE_IP_LIMIT = 6
interface ServiceIpListProps {
ips: string[]
className?: string
emptyLabel?: string
copyable?: boolean
textClassName?: string
}
export function ServiceIpList({
ips,
className,
emptyLabel = 'Нет IP',
copyable = false,
textClassName,
}: ServiceIpListProps) {
if (ips.length === 0) {
return (
<span className={cn('text-muted-foreground text-xs', className)}>
{emptyLabel}
</span>
)
}
const visible = ips.slice(0, VISIBLE_IP_LIMIT)
const extraCount = ips.length - visible.length
const copyValue = ips.join('\n')
return (
<div className={cn('flex min-w-0 items-center gap-1.5', className)}>
<TruncatedText
className={cn(
'text-muted-foreground min-w-0 font-mono text-xs',
textClassName,
)}
>
{visible.join(' · ')}
</TruncatedText>
{extraCount > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Badge
variant="outline"
size="xs"
className="shrink-0 tabular-nums"
/>
}
>
ещё {extraCount}
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<ul className="flex flex-col gap-0.5 font-mono text-xs">
{ips.map((ip) => (
<li key={ip}>{ip}</li>
))}
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
{copyable ? <CopyFqdnButton value={copyValue} /> : null}
</div>
)
}
@@ -1,392 +0,0 @@
import { useState } from 'react'
import { Link } from '@tanstack/react-router'
import {
ChevronDownIcon,
FolderOpenIcon,
MoreHorizontalIcon,
PlusIcon,
} from 'lucide-react'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { CopyFqdnButton, ServiceFqdnList } from '@/components/services/service-fqdn-list'
import { ServiceGroupIcon } from '@/components/service-group-icon'
import { TruncatedText } from '@/components/truncated-text'
import type { ServiceGroup, ServiceGroupView, ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@cfdm/ui/components/collapsible'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import {
Item,
ItemActions,
ItemContent,
ItemTitle,
} from '@cfdm/ui/components/item'
import { Separator } from '@cfdm/ui/components/separator'
import { Switch } from '@cfdm/ui/components/switch'
import { cn } from '@cfdm/ui/lib/utils'
const VISIBLE_SERVICE_LIMIT = 5
const GROUP_TYPE_LABELS: Record<ServiceGroup['type'], string> = {
vpn: 'VPN',
network: 'Сеть',
internet: 'Интернет',
bgp: 'BGP',
custom: 'Другое',
}
function servicesCountLabel(count: number): string {
const mod10 = count % 10
const mod100 = count % 100
if (mod10 === 1 && mod100 !== 11) return `${count} сервис`
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
return `${count} сервиса`
}
return `${count} сервисов`
}
interface ServiceGroupUnitProps {
group: ServiceGroupView | null
services: ServiceView[]
togglingId: number | null
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
onEditGroup: (group: ServiceGroupView) => void
onDeleteGroup: (group: ServiceGroupView) => void
onAddServiceToGroup: (groupId: number | null) => void
}
export function ServiceGroupUnit({
group,
services,
togglingId,
onEditService,
onDeleteService,
onToggleService,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
}: ServiceGroupUnitProps) {
const [moreOpen, setMoreOpen] = useState(false)
const title = group?.name ?? 'Без группы'
const typeLabel = group ? GROUP_TYPE_LABELS[group.type] : null
const commonDomain = group?.domain?.trim() || null
const visible = services.slice(0, VISIBLE_SERVICE_LIMIT)
const rest = services.slice(VISIBLE_SERVICE_LIMIT)
const groupId = group?.id ?? null
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<IconTile
variant="elevated"
size="sm"
className="text-muted-foreground"
aria-hidden="true"
>
{group ? (
<ServiceGroupIcon type={group.type} />
) : (
<FolderOpenIcon />
)}
</IconTile>
<div className="flex min-w-0 flex-col gap-1">
<div className="flex min-w-0 items-center gap-2">
<FrameTitle className="truncate">{title}</FrameTitle>
<Badge variant="outline" size="xs" className="shrink-0 tabular-nums">
{services.length}
</Badge>
</div>
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs">
<span>{servicesCountLabel(services.length)}</span>
{typeLabel ? (
<>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{typeLabel}</span>
</>
) : null}
</FrameDescription>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{group ? (
<HealthCheckBadge
status={group.health_status ?? 'unknown'}
latencyMs={group.health_latency_ms}
size="xs"
/>
) : null}
{group ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия группы ${group.name}`}
/>
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onAddServiceToGroup(group.id)}>
<PlusIcon aria-hidden />
Добавить сервис
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditGroup(group)}>
Изменить группу
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteGroup(group)}
>
Удалить группу
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="Добавить сервис без группы"
onClick={() => onAddServiceToGroup(null)}
>
<PlusIcon aria-hidden />
</Button>
)}
</div>
</FrameHeader>
<FramePanel className="p-0 shadow-none!">
{group ? (
<>
<Separator />
<div className="flex items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-muted-foreground text-xs">Общий домен</span>
{commonDomain ? (
<TruncatedText className="min-w-0 font-mono text-sm">
{commonDomain}
</TruncatedText>
) : (
<span className="text-muted-foreground text-sm">Не задан</span>
)}
</div>
{commonDomain ? (
<CopyFqdnButton className="shrink-0" value={commonDomain} />
) : null}
</div>
</>
) : null}
<Separator />
<div className="flex flex-col gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<p className="text-muted-foreground text-xs">Сервисы</p>
{services.length === 0 ? (
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-muted-foreground text-sm">Нет сервисов</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onAddServiceToGroup(groupId)}
>
<PlusIcon data-icon="inline-start" aria-hidden />
Добавить сервис
</Button>
</div>
) : (
<div className="flex flex-col">
{visible.map((service, index) => (
<ServiceUnitRow
key={service.id}
service={service}
togglingId={togglingId}
showSeparator={index > 0}
onEditService={onEditService}
onDeleteService={onDeleteService}
onToggleService={onToggleService}
/>
))}
{rest.length > 0 ? (
<Collapsible open={moreOpen} onOpenChange={setMoreOpen}>
<CollapsibleContent>
{rest.map((service) => (
<ServiceUnitRow
key={service.id}
service={service}
togglingId={togglingId}
showSeparator
onEditService={onEditService}
onDeleteService={onDeleteService}
onToggleService={onToggleService}
/>
))}
</CollapsibleContent>
<div className="flex justify-end pt-2">
<CollapsibleTrigger
render={
<Button
type="button"
variant="ghost"
size="sm"
aria-expanded={moreOpen}
/>
}
>
{moreOpen
? 'Скрыть'
: `Показать ещё ${rest.length}`}
<ChevronDownIcon
aria-hidden
data-icon="inline-end"
className={cn(
'transition-transform duration-200',
moreOpen && 'rotate-180',
)}
/>
</CollapsibleTrigger>
</div>
</Collapsible>
) : null}
</div>
)}
</div>
</FramePanel>
</Frame>
)
}
function ServiceUnitRow({
service,
togglingId,
showSeparator,
onEditService,
onDeleteService,
onToggleService,
}: {
service: ServiceView
togglingId: number | null
showSeparator: boolean
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
}) {
return (
<>
{showSeparator ? <Separator /> : null}
<Item size="sm" className="rounded-none px-0 py-2.5">
<ItemContent className="min-w-0 gap-1 sm:hidden">
<ItemTitle className="min-w-0">
<Link
to="/services/$serviceId"
params={{ serviceId: String(service.id) }}
className="truncate hover:underline"
>
{service.name}
</Link>
</ItemTitle>
<ServiceFqdnList copyable service={service} />
</ItemContent>
<ItemContent className="hidden min-w-0 sm:flex sm:flex-row sm:items-center sm:gap-3">
<ItemTitle className="min-w-0 shrink-0">
<Link
to="/services/$serviceId"
params={{ serviceId: String(service.id) }}
className="truncate hover:underline"
>
{service.name}
</Link>
</ItemTitle>
<ServiceFqdnList
copyable
className="min-w-0 flex-1 justify-end"
service={service}
/>
</ItemContent>
<ItemActions className="shrink-0">
<Switch
size="sm"
checked={service.enabled}
disabled={togglingId === service.id}
onCheckedChange={(checked) =>
onToggleService(service.id, Boolean(checked))
}
aria-label={
service.enabled ? 'Выключить сервис' : 'Включить сервис'
}
/>
<HealthCheckBadge
status={service.health_status ?? 'unknown'}
latencyMs={service.health_latency_ms}
size="xs"
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия ${service.name}`}
/>
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
render={
<Link
to="/services/$serviceId"
params={{ serviceId: String(service.id) }}
/>
}
>
Обзор
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditService(service)}>
Изменить
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteService(service)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ItemActions>
</Item>
</>
)
}
@@ -0,0 +1,162 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
import { HealthCheckBadge } from '@/components/health-check-badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import {
ServiceFqdnList,
ServiceIpList,
} from '@/components/services/service-fqdn-list'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { Separator } from '@cfdm/ui/components/separator'
import { Switch } from '@cfdm/ui/components/switch'
interface ServiceUnitCardProps {
service: ServiceView
togglingId: number | null
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
}
export function ServiceUnitCard({
service,
togglingId,
onEditService,
onDeleteService,
onToggleService,
}: ServiceUnitCardProps) {
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<IconTile
variant="elevated"
className="size-10.5 text-muted-foreground"
aria-hidden="true"
>
<ServerIcon />
</IconTile>
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle className="min-w-0 truncate">
<Link
to="/services/$serviceId"
params={{ serviceId: String(service.id) }}
className="hover:underline"
>
{service.name}
</Link>
</FrameTitle>
<FrameDescription className="truncate font-mono">
{service.slug}
</FrameDescription>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<HealthCheckBadge
status={service.health_status ?? 'unknown'}
latencyMs={service.health_latency_ms}
size="xs"
/>
<Switch
size="sm"
checked={service.enabled}
disabled={togglingId === service.id}
onCheckedChange={(checked) =>
onToggleService(service.id, Boolean(checked))
}
aria-label={
service.enabled ? 'Выключить сервис' : 'Включить сервис'
}
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия ${service.name}`}
/>
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
render={
<Link
to="/services/$serviceId"
params={{ serviceId: String(service.id) }}
/>
}
>
Обзор
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditService(service)}>
Изменить
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteService(service)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</FrameHeader>
<FramePanel className="p-0 shadow-none!">
<Separator />
<ServiceLabeledRow label="Общий домен">
<ServiceFqdnList
copyable
service={service}
emptyLabel="Не задан"
textClassName="text-foreground text-sm"
/>
</ServiceLabeledRow>
<Separator />
<ServiceLabeledRow label="IP">
<ServiceIpList
copyable
ips={service.ips ?? []}
emptyLabel="Нет IP"
textClassName="text-foreground text-sm"
/>
</ServiceLabeledRow>
</FramePanel>
</Frame>
)
}
function ServiceLabeledRow({
label,
children,
}: {
label: string
children: ReactNode
}) {
return (
<div className="flex min-w-0 flex-col gap-1 px-(--frame-panel-header-px) py-(--frame-panel-header-py) sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
<div className="min-w-0 sm:flex sm:justify-end">{children}</div>
</div>
)
}
@@ -22,7 +22,7 @@ import {
applyFiltersToData,
getActiveFilters,
} from '@/components/reui-kit/filter-utils'
import { ServiceGroupUnit } from '@/components/services/service-group-unit'
import { ServiceCatalogSection } from '@/components/services/service-catalog-section'
import {
SERVICE_TABS,
createDefaultServiceFilters,
@@ -296,7 +296,7 @@ export function ServicesGroupedCatalog({
<FrameDescription>
{domainLabel
? `Каталог сервисов с привязками к ${domainLabel}`
: 'Группы, общий домен и FQDN сервисов'}
: 'Группы для сортировки; у каждого сервиса — общий домен и IP'}
</FrameDescription>
</div>
{primaryAction ? (
@@ -370,9 +370,9 @@ export function ServicesGroupedCatalog({
/>
</div>
) : (
<div className="flex flex-col gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<div className="flex flex-col gap-6 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
{units.map((unit) => (
<ServiceGroupUnit
<ServiceCatalogSection
key={unit.id}
group={unit.group}
services={unit.services}
+1 -2
View File
@@ -456,7 +456,6 @@ function ServicesPage() {
board.columns.map((column, index) => ({
id: column.id,
title: column.title,
description: column.domain ?? undefined,
healthStatus: column.group?.health_status,
healthLatencyMs: column.group?.health_latency_ms,
dotClassName: GROUP_DOT_COLORS[index % GROUP_DOT_COLORS.length],
@@ -494,7 +493,7 @@ function ServicesPage() {
const pageDescription = filteredDomain
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
: 'Группы, общий домен и FQDN сервисов'
: 'Группы для сортировки; у каждого сервиса — общий домен и IP'
const sheets = (
<>