Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50c5c21c18 | ||
|
|
ab4ccbd7a1 |
@@ -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 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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { TruncatedText } from '@/components/truncated-text'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { serviceDisplayFqdns } from '@/lib/service-utils'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -10,16 +15,53 @@ import {
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export function CopyFqdnButton({
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
value: string
|
||||
className?: string
|
||||
}) {
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard({
|
||||
onCopy: () => toast.success('Скопировано'),
|
||||
})
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={className}
|
||||
aria-label={isCopied ? 'Скопировано' : `Скопировать ${value}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
copyToClipboard(value)
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CheckIcon className="text-success" aria-hidden />
|
||||
) : (
|
||||
<CopyIcon aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServiceFqdnListProps {
|
||||
service: ServiceView
|
||||
className?: string
|
||||
emptyLabel?: string
|
||||
copyable?: boolean
|
||||
textClassName?: string
|
||||
}
|
||||
|
||||
export function ServiceFqdnList({
|
||||
service,
|
||||
className,
|
||||
emptyLabel = 'FQDN не задан',
|
||||
emptyLabel = 'Нет FQDN',
|
||||
copyable = false,
|
||||
textClassName,
|
||||
}: ServiceFqdnListProps) {
|
||||
const fqdns = serviceDisplayFqdns(service)
|
||||
if (fqdns.length === 0) {
|
||||
@@ -32,10 +74,16 @@ export function ServiceFqdnList({
|
||||
|
||||
const [first, ...rest] = fqdns
|
||||
const extraCount = rest.length
|
||||
const copyValue = fqdns.join('\n')
|
||||
|
||||
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 ? (
|
||||
@@ -62,6 +110,75 @@ export function ServiceFqdnList({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
{copyable ? <CopyFqdnButton value={copyValue} /> : null}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
useReactTable,
|
||||
type ExpandedState,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
FilterIcon,
|
||||
@@ -14,24 +8,21 @@ import {
|
||||
ServerIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { Filters, type Filter } from '@/components/reui/filters'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { applyFiltersToData } from '@/components/reui-kit/filter-utils'
|
||||
import { createServicesGroupedColumns } from '@/components/services/services-grouped-columns'
|
||||
import type { ServiceCatalogTreeRow } from '@/components/services/services-grouped-columns'
|
||||
import {
|
||||
applyFiltersToData,
|
||||
getActiveFilters,
|
||||
} from '@/components/reui-kit/filter-utils'
|
||||
import { ServiceCatalogSection } from '@/components/services/service-catalog-section'
|
||||
import {
|
||||
SERVICE_TABS,
|
||||
createDefaultServiceFilters,
|
||||
@@ -98,60 +89,51 @@ function catalogTabFilter(row: ServiceCatalogRow, tabId: string) {
|
||||
return serviceTabFilter(row, tabId)
|
||||
}
|
||||
|
||||
function buildTreeRows(
|
||||
interface GroupUnitData {
|
||||
id: string
|
||||
group: ServiceGroupView | null
|
||||
services: ServiceView[]
|
||||
}
|
||||
|
||||
function buildGroupUnits(
|
||||
data: ServiceGroupsResponse,
|
||||
filteredServiceIds: Set<number>,
|
||||
domainId?: number,
|
||||
): ServiceCatalogTreeRow[] {
|
||||
const rows: ServiceCatalogTreeRow[] = []
|
||||
domainId: number | undefined,
|
||||
showEmptyGroups: boolean,
|
||||
): GroupUnitData[] {
|
||||
const units: GroupUnitData[] = []
|
||||
|
||||
for (const group of data.groups) {
|
||||
const services = group.services
|
||||
.filter((s) => serviceMatchesDomain(s, domainId))
|
||||
.filter((s) => filteredServiceIds.has(s.id))
|
||||
if (services.length === 0) continue
|
||||
const matchingDomain = group.services.filter((service) =>
|
||||
serviceMatchesDomain(service, domainId),
|
||||
)
|
||||
const services = matchingDomain.filter((service) =>
|
||||
filteredServiceIds.has(service.id),
|
||||
)
|
||||
|
||||
rows.push({
|
||||
kind: 'group',
|
||||
id: `group-${group.id}`,
|
||||
group,
|
||||
subRows: services.map((service) => ({
|
||||
kind: 'service' as const,
|
||||
id: `service-${service.id}`,
|
||||
service,
|
||||
groupId: group.id,
|
||||
groupName: group.name,
|
||||
})),
|
||||
})
|
||||
if (services.length === 0) {
|
||||
if (showEmptyGroups && matchingDomain.length === 0) {
|
||||
units.push({ id: `group-${group.id}`, group, services: [] })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
units.push({ id: `group-${group.id}`, group, services })
|
||||
}
|
||||
|
||||
const ungrouped = data.ungrouped
|
||||
.filter((s) => serviceMatchesDomain(s, domainId))
|
||||
.filter((s) => filteredServiceIds.has(s.id))
|
||||
.filter((service) => serviceMatchesDomain(service, domainId))
|
||||
.filter((service) => filteredServiceIds.has(service.id))
|
||||
|
||||
if (ungrouped.length > 0) {
|
||||
rows.push({
|
||||
kind: 'group',
|
||||
units.push({
|
||||
id: 'group-ungrouped',
|
||||
group: null,
|
||||
subRows: ungrouped.map((service) => ({
|
||||
kind: 'service' as const,
|
||||
id: `service-${service.id}`,
|
||||
service,
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
})),
|
||||
services: ungrouped,
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
function defaultExpanded(rows: ServiceCatalogTreeRow[]): ExpandedState {
|
||||
return rows.reduce<Record<string, boolean>>((acc, row) => {
|
||||
acc[row.id] = true
|
||||
return acc
|
||||
}, {})
|
||||
return units
|
||||
}
|
||||
|
||||
export function ServicesAddMenu({
|
||||
@@ -229,7 +211,6 @@ export function ServicesGroupedCatalog({
|
||||
const [filters, setFilters] = useState<Filter[]>(() =>
|
||||
createDefaultServiceFilters(),
|
||||
)
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({})
|
||||
const filterFields = useServiceFilterFields()
|
||||
|
||||
const flatRows = useMemo(() => {
|
||||
@@ -263,60 +244,25 @@ export function ServicesGroupedCatalog({
|
||||
return new Set(afterFilters.map((r) => r.id))
|
||||
}, [flatRows, tab, filters])
|
||||
|
||||
const treeData = useMemo(
|
||||
() => buildTreeRows(data, filteredIds, domainId),
|
||||
[data, filteredIds, domainId],
|
||||
const showEmptyGroups =
|
||||
tab === 'all' && domainId == null && getActiveFilters(filters).length === 0
|
||||
|
||||
const units = useMemo(
|
||||
() => buildGroupUnits(data, filteredIds, domainId, showEmptyGroups),
|
||||
[data, filteredIds, domainId, showEmptyGroups],
|
||||
)
|
||||
|
||||
const expandedKey = treeData.map((r) => r.id).join(',')
|
||||
useEffect(() => {
|
||||
setExpanded(defaultExpanded(treeData))
|
||||
}, [expandedKey, treeData])
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createServicesGroupedColumns({
|
||||
onEditService,
|
||||
onDeleteService,
|
||||
onToggleService,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
onAddServiceToGroup,
|
||||
togglingId,
|
||||
}),
|
||||
[
|
||||
onEditService,
|
||||
onDeleteService,
|
||||
onToggleService,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
onAddServiceToGroup,
|
||||
togglingId,
|
||||
],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: treeData,
|
||||
columns,
|
||||
state: { expanded },
|
||||
onExpandedChange: setExpanded,
|
||||
getSubRows: (row) => (row.kind === 'group' ? row.subRows : undefined),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="mt-1 h-4 w-72" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-3 p-4">
|
||||
<Skeleton className="h-9 w-full max-w-md" />
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 w-full rounded-xl" />
|
||||
))}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
@@ -342,112 +288,106 @@ export function ServicesGroupedCatalog({
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredIds.size}
|
||||
emptyMessage="Нет записей по выбранным фильтрам."
|
||||
tableLayout={{ dense: true }}
|
||||
>
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
{!hideHeader ? (
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle>Сервисы</FrameTitle>
|
||||
<FrameDescription>
|
||||
{domainLabel
|
||||
? `Каталог сервисов с привязками к ${domainLabel}`
|
||||
: 'Группы, FQDN и доступность сервисов'}
|
||||
</FrameDescription>
|
||||
</div>
|
||||
{primaryAction ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{primaryAction}
|
||||
</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
) : null}
|
||||
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||
<CountedLineTabs
|
||||
tabs={ALL_TABS.map((t) => ({
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
count: tabCounts[t.id] ?? 0,
|
||||
}))}
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
/>
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
{!hideHeader ? (
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle>Сервисы</FrameTitle>
|
||||
<FrameDescription>
|
||||
{domainLabel
|
||||
? `Каталог сервисов с привязками к ${domainLabel}`
|
||||
: 'Группы для сортировки; у каждого сервиса — общий домен и IP'}
|
||||
</FrameDescription>
|
||||
</div>
|
||||
{primaryAction ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{primaryAction}
|
||||
</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
) : null}
|
||||
|
||||
<Separator />
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||
<CountedLineTabs
|
||||
tabs={ALL_TABS.map((t) => ({
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
count: tabCounts[t.id] ?? 0,
|
||||
}))}
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={setFilters}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden />
|
||||
Фильтры
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={setFilters}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden />
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTab('all')
|
||||
setFilters(createDefaultServiceFilters())
|
||||
}}
|
||||
>
|
||||
<FunnelXIcon className="size-4" aria-hidden />
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{units.length === 0 ? (
|
||||
<div className="p-6">
|
||||
<EmptyState
|
||||
title="Нет совпадений"
|
||||
description="Измените фильтры или вкладку."
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTab('all')
|
||||
setFilters(createDefaultServiceFilters())
|
||||
}}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTab('all')
|
||||
setFilters(createDefaultServiceFilters())
|
||||
}}
|
||||
>
|
||||
<FunnelXIcon className="size-4" aria-hidden />
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{treeData.length === 0 ? (
|
||||
<div className="p-6">
|
||||
<EmptyState
|
||||
title="Нет совпадений"
|
||||
description="Измените фильтры или вкладку."
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTab('all')
|
||||
setFilters(createDefaultServiceFilters())
|
||||
}}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
}
|
||||
) : (
|
||||
<div className="flex flex-col gap-6 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||
{units.map((unit) => (
|
||||
<ServiceCatalogSection
|
||||
key={unit.id}
|
||||
group={unit.group}
|
||||
services={unit.services}
|
||||
togglingId={togglingId}
|
||||
onEditService={onEditService}
|
||||
onDeleteService={onDeleteService}
|
||||
onToggleService={onToggleService}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
onAddServiceToGroup={onAddServiceToGroup}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
<Separator />
|
||||
<FrameFooter>
|
||||
<DataGridPagination
|
||||
sizes={[5, 10, 20, 50]}
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
info="{from} - {to} of {count}"
|
||||
previousPageLabel="Предыдущая"
|
||||
nextPageLabel="Следующая"
|
||||
/>
|
||||
</FrameFooter>
|
||||
</>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
FolderIcon,
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
|
||||
import { ServiceFqdnList } from '@/components/services/service-fqdn-list'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export type ServiceTreeServiceRow = {
|
||||
kind: 'service'
|
||||
id: string
|
||||
service: ServiceView
|
||||
groupId: number | null
|
||||
groupName: string | null
|
||||
}
|
||||
|
||||
export type ServiceTreeGroupRow = {
|
||||
kind: 'group'
|
||||
id: string
|
||||
group: ServiceGroupView | null
|
||||
subRows: ServiceTreeServiceRow[]
|
||||
}
|
||||
|
||||
export type ServiceCatalogTreeRow = ServiceTreeGroupRow | ServiceTreeServiceRow
|
||||
|
||||
function isServiceRow(row: ServiceCatalogTreeRow): row is ServiceTreeServiceRow {
|
||||
return row.kind === 'service'
|
||||
}
|
||||
|
||||
export function createServicesGroupedColumns({
|
||||
onEditService,
|
||||
onDeleteService,
|
||||
onToggleService,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
onAddServiceToGroup,
|
||||
togglingId,
|
||||
}: {
|
||||
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
|
||||
togglingId: number | null
|
||||
}): ColumnDef<ServiceCatalogTreeRow>[] {
|
||||
return [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (row) =>
|
||||
isServiceRow(row) ? row.service.name : (row.group?.name ?? 'Без группы'),
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Группа / сервис" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const original = row.original
|
||||
if (!isServiceRow(original)) {
|
||||
const title = original.group?.name ?? 'Без группы'
|
||||
const domain = original.group?.domain
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={
|
||||
row.getIsExpanded() ? `Свернуть ${title}` : `Развернуть ${title}`
|
||||
}
|
||||
aria-expanded={row.getIsExpanded()}
|
||||
className="text-muted-foreground hover:text-foreground size-6 shrink-0 p-0 shadow-none"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
row.getToggleExpandedHandler()()
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 transition-transform duration-150',
|
||||
row.getIsExpanded() && 'rotate-90',
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
<FolderIcon
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold">{title}</span>
|
||||
<Badge variant="outline" size="xs" className="shrink-0">
|
||||
{original.subRows.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{domain ? (
|
||||
<span className="text-muted-foreground truncate font-mono text-xs">
|
||||
{domain}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5 pl-8">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{original.service.name}
|
||||
</span>
|
||||
<ServiceFqdnList service={original.service} emptyLabel="—" />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
minSize: 260,
|
||||
meta: { headerTitle: 'Группа / сервис', autoSize: true },
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Доступность" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const original = row.original
|
||||
if (!isServiceRow(original)) {
|
||||
return (
|
||||
<HealthCheckBadge
|
||||
status={original.group?.health_status ?? 'unknown'}
|
||||
latencyMs={original.group?.health_latency_ms}
|
||||
size="xs"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<HealthCheckBadge
|
||||
status={original.service.health_status ?? 'unknown'}
|
||||
latencyMs={original.service.health_latency_ms}
|
||||
size="xs"
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Статус" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const original = row.original
|
||||
if (!isServiceRow(original)) return null
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={original.service.enabled}
|
||||
disabled={togglingId === original.service.id}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleService(original.service.id, Boolean(checked))
|
||||
}
|
||||
aria-label={
|
||||
original.service.enabled
|
||||
? 'Выключить сервис'
|
||||
: 'Включить сервис'
|
||||
}
|
||||
/>
|
||||
<StatusBadge
|
||||
status={original.service.enabled ? 'active' : 'disabled'}
|
||||
label={original.service.enabled ? 'Вкл' : 'Выкл'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
const original = row.original
|
||||
if (!isServiceRow(original)) {
|
||||
if (!original.group) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Добавить сервис без группы"
|
||||
onClick={() => onAddServiceToGroup(null)}
|
||||
>
|
||||
<PlusIcon aria-hidden />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Действия группы ${original.group.name}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onAddServiceToGroup(original.group!.id)}
|
||||
>
|
||||
<PlusIcon aria-hidden />
|
||||
Добавить сервис
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEditGroup(original.group!)}>
|
||||
Изменить группу
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDeleteGroup(original.group!)}
|
||||
>
|
||||
Удалить группу
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Действия ${original.service.name}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
to="/services/$serviceId"
|
||||
params={{ serviceId: String(original.service.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEditService(original.service)}>
|
||||
Изменить
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDeleteService(original.service)}
|
||||
>
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
},
|
||||
size: 56,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function useServicesGroupedColumns(
|
||||
args: Parameters<typeof createServicesGroupedColumns>[0],
|
||||
) {
|
||||
return useMemo(() => createServicesGroupedColumns(args), [
|
||||
args.onEditService,
|
||||
args.onDeleteService,
|
||||
args.onToggleService,
|
||||
args.onEditGroup,
|
||||
args.onDeleteGroup,
|
||||
args.onAddServiceToGroup,
|
||||
args.togglingId,
|
||||
])
|
||||
}
|
||||
@@ -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 = (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user