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
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.
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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