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 { Badge } from '@/components/reui/badge'
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { StatusBadge } from '@/components/status-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 type { ServiceView } from '@/lib/schemas'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import {
|
import {
|
||||||
@@ -78,7 +78,18 @@ export function ServiceKanbanCard({
|
|||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
|
|
||||||
<ItemContent className="min-w-0 gap-2">
|
<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>
|
</ItemContent>
|
||||||
|
|
||||||
<ItemFooter className="min-w-0 justify-between gap-2">
|
<ItemFooter className="min-w-0 justify-between gap-2">
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
import { Separator } from '@cfdm/ui/components/separator'
|
|
||||||
|
|
||||||
interface BindingHealthConfig {
|
interface BindingHealthConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -179,6 +178,7 @@ export function ServiceEditSheet({
|
|||||||
const [slug, setSlug] = useState('')
|
const [slug, setSlug] = useState('')
|
||||||
const [serviceGroupId, setServiceGroupId] = useState('none')
|
const [serviceGroupId, setServiceGroupId] = useState('none')
|
||||||
const [ips, setIps] = useState<string[]>([])
|
const [ips, setIps] = useState<string[]>([])
|
||||||
|
const [commonFqdn, setCommonFqdn] = useState('')
|
||||||
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
|
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
|
||||||
const [lbWeight, setLbWeight] = useState(1)
|
const [lbWeight, setLbWeight] = useState(1)
|
||||||
const [lbPriority, setLbPriority] = useState(1)
|
const [lbPriority, setLbPriority] = useState(1)
|
||||||
@@ -192,13 +192,6 @@ export function ServiceEditSheet({
|
|||||||
[groups],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setActiveTab('general')
|
setActiveTab('general')
|
||||||
@@ -209,7 +202,9 @@ export function ServiceEditSheet({
|
|||||||
service.service_group_id != null ? String(service.service_group_id) : 'none',
|
service.service_group_id != null ? String(service.service_group_id) : 'none',
|
||||||
)
|
)
|
||||||
setIps(service.ips ?? [])
|
setIps(service.ips ?? [])
|
||||||
setBindings(toBindingDrafts(service))
|
const drafts = toBindingDrafts(service)
|
||||||
|
setBindings(drafts)
|
||||||
|
setCommonFqdn(drafts[0]?.fqdn ?? '')
|
||||||
setLbWeight(service.lb_weight ?? 1)
|
setLbWeight(service.lb_weight ?? 1)
|
||||||
setLbPriority(service.lb_priority ?? 1)
|
setLbPriority(service.lb_priority ?? 1)
|
||||||
return
|
return
|
||||||
@@ -221,6 +216,7 @@ export function ServiceEditSheet({
|
|||||||
defaultGroupId != null ? String(defaultGroupId) : 'none',
|
defaultGroupId != null ? String(defaultGroupId) : 'none',
|
||||||
)
|
)
|
||||||
setIps([])
|
setIps([])
|
||||||
|
setCommonFqdn('')
|
||||||
setBindings([])
|
setBindings([])
|
||||||
setLbWeight(1)
|
setLbWeight(1)
|
||||||
setLbPriority(1)
|
setLbPriority(1)
|
||||||
@@ -232,27 +228,46 @@ export function ServiceEditSheet({
|
|||||||
[knownDomains],
|
[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() {
|
function handleAddBinding() {
|
||||||
setBindings((current) => [
|
setBindings((current) => [
|
||||||
...current,
|
...current,
|
||||||
{
|
emptyBindingDraft(current.length === 0 ? commonFqdn : ''),
|
||||||
fqdn: '',
|
|
||||||
record_type: 'A',
|
|
||||||
target_ips: [],
|
|
||||||
target_cname: '',
|
|
||||||
lb_mode: 'round_robin',
|
|
||||||
health: { ...defaultHealth },
|
|
||||||
target_ip_weights: {},
|
|
||||||
target_ip_priorities: {},
|
|
||||||
},
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRemoveBinding(index: number) {
|
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) {
|
function handleFqdnChange(index: number, fqdn: string) {
|
||||||
|
if (index === 0) setCommonFqdn(fqdn)
|
||||||
setBindings((current) =>
|
setBindings((current) =>
|
||||||
current.map((item, i) => (i === index ? { ...item, fqdn } : item)),
|
current.map((item, i) => (i === index ? { ...item, fqdn } : item)),
|
||||||
)
|
)
|
||||||
@@ -343,8 +358,46 @@ export function ServiceEditSheet({
|
|||||||
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
|
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() {
|
function handleSubmit() {
|
||||||
const domains = buildDomainsPayload(bindings)
|
const syncedBindings = syncCommonDomain(bindings)
|
||||||
|
const domains = buildDomainsPayload(syncedBindings)
|
||||||
const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase())
|
const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase())
|
||||||
const hasDuplicateFqdn =
|
const hasDuplicateFqdn =
|
||||||
new Set(normalizedFqdns).size !== normalizedFqdns.length
|
new Set(normalizedFqdns).size !== normalizedFqdns.length
|
||||||
@@ -354,13 +407,11 @@ export function ServiceEditSheet({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const groupId = resolveServiceGroupId()
|
const groupId = resolveServiceGroupId()
|
||||||
const lbFields = groupHasDomain
|
|
||||||
? { lb_weight: lbWeight, lb_priority: lbPriority }
|
|
||||||
: {}
|
|
||||||
const configPayload = {
|
const configPayload = {
|
||||||
ips,
|
ips,
|
||||||
domains,
|
domains,
|
||||||
...lbFields,
|
lb_weight: lbWeight,
|
||||||
|
lb_priority: lbPriority,
|
||||||
}
|
}
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
onCreate?.({
|
onCreate?.({
|
||||||
@@ -368,7 +419,8 @@ export function ServiceEditSheet({
|
|||||||
slug: slug.trim(),
|
slug: slug.trim(),
|
||||||
service_group_id: groupId,
|
service_group_id: groupId,
|
||||||
ips,
|
ips,
|
||||||
...lbFields,
|
lb_weight: lbWeight,
|
||||||
|
lb_priority: lbPriority,
|
||||||
domains,
|
domains,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -398,9 +450,8 @@ export function ServiceEditSheet({
|
|||||||
<SheetHeader className="shrink-0 border-b pb-4">
|
<SheetHeader className="shrink-0 border-b pb-4">
|
||||||
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Настройте параметры сервиса и привязки FQDN → IP или CNAME. Один
|
Общий домен и IP задаются у сервиса. Дополнительные FQDN — на вкладке
|
||||||
сервис может иметь несколько FQDN в разных зонах; зона определяется
|
привязок; зона определяется из FQDN автоматически.
|
||||||
из FQDN автоматически.
|
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
@@ -458,6 +509,20 @@ export function ServiceEditSheet({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</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>
|
<Field>
|
||||||
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
|
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
|
||||||
<TaggedInput
|
<TaggedInput
|
||||||
@@ -469,48 +534,6 @@ export function ServiceEditSheet({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</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>
|
||||||
|
|
||||||
<TabsContent value="bindings" className="flex flex-col gap-4">
|
<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 { useForm, Controller } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import {
|
import {
|
||||||
@@ -12,10 +12,6 @@ import { FormFieldSimple } from '@/components/form-field'
|
|||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { AppFieldGroup } from '@/components/app-field'
|
import { AppFieldGroup } from '@/components/app-field'
|
||||||
import { AppInput } from '@/components/app-input'
|
import { AppInput } from '@/components/app-input'
|
||||||
import {
|
|
||||||
HealthCheckConfigFields,
|
|
||||||
type LbAndHealthConfig,
|
|
||||||
} from '@/components/health-check-config-fields'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -44,19 +40,6 @@ interface ServiceGroupEditSheetProps {
|
|||||||
onSave?: (id: number, body: CreateServiceGroupInput) => void
|
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({
|
export function ServiceGroupEditSheet({
|
||||||
mode,
|
mode,
|
||||||
group,
|
group,
|
||||||
@@ -74,7 +57,6 @@ export function ServiceGroupEditSheet({
|
|||||||
domain: null,
|
domain: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const [lbHealth, setLbHealth] = useState<LbAndHealthConfig>(defaultLbHealth)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -82,44 +64,19 @@ export function ServiceGroupEditSheet({
|
|||||||
form.reset({
|
form.reset({
|
||||||
name: group.name,
|
name: group.name,
|
||||||
type: group.type,
|
type: group.type,
|
||||||
domain: group.domain ?? null,
|
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',
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
form.reset({ name: '', type: 'custom', domain: null })
|
form.reset({ name: '', type: 'custom', domain: null })
|
||||||
setLbHealth(defaultLbHealth)
|
|
||||||
}
|
}
|
||||||
}, [open, mode, group, form])
|
}, [open, mode, group, form])
|
||||||
|
|
||||||
const domainValue = form.watch('domain')
|
|
||||||
const hasDomain = Boolean(domainValue?.trim())
|
|
||||||
|
|
||||||
function handleSubmit(values: ServiceGroupFormValues) {
|
function handleSubmit(values: ServiceGroupFormValues) {
|
||||||
const body: CreateServiceGroupInput = {
|
const body: CreateServiceGroupInput = {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
type: values.type ?? 'custom',
|
type: values.type ?? 'custom',
|
||||||
icon: values.icon,
|
icon: values.icon,
|
||||||
domain: values.domain?.trim() || null,
|
domain: 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,
|
|
||||||
}
|
}
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
onCreate?.(body)
|
onCreate?.(body)
|
||||||
@@ -133,7 +90,7 @@ export function ServiceGroupEditSheet({
|
|||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title={mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
|
title={mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
|
||||||
description="Домен группы необязателен. Если указан FQDN (gr.ivx.su, domain.new.ivx.su), он публикуется в Cloudflare отдельно от привязок сервисов."
|
description="Группа нужна только для сортировки каталога. Общий домен и IP задаются у каждого сервиса."
|
||||||
form={form}
|
form={form}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
contentClassName="gap-6"
|
contentClassName="gap-6"
|
||||||
@@ -186,26 +143,7 @@ export function ServiceGroupEditSheet({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</FormFieldSimple>
|
</FormFieldSimple>
|
||||||
<FormFieldSimple
|
|
||||||
label="Домен группы (FQDN, необязательно)"
|
|
||||||
htmlFor="group-domain"
|
|
||||||
>
|
|
||||||
<AppInput
|
|
||||||
id="group-domain"
|
|
||||||
placeholder="domain.new.ivx.su"
|
|
||||||
{...form.register('domain')}
|
|
||||||
/>
|
|
||||||
</FormFieldSimple>
|
|
||||||
</AppFieldGroup>
|
</AppFieldGroup>
|
||||||
|
|
||||||
{hasDomain ? (
|
|
||||||
<HealthCheckConfigFields
|
|
||||||
value={lbHealth}
|
|
||||||
onChange={setLbHealth}
|
|
||||||
lbModeLabel="Режим балансировки общего домена"
|
|
||||||
idPrefix="group-lb-health"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</FormSheet>
|
</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
|
className?: string
|
||||||
emptyLabel?: string
|
emptyLabel?: string
|
||||||
copyable?: boolean
|
copyable?: boolean
|
||||||
|
textClassName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServiceFqdnList({
|
export function ServiceFqdnList({
|
||||||
@@ -60,6 +61,7 @@ export function ServiceFqdnList({
|
|||||||
className,
|
className,
|
||||||
emptyLabel = 'Нет FQDN',
|
emptyLabel = 'Нет FQDN',
|
||||||
copyable = false,
|
copyable = false,
|
||||||
|
textClassName,
|
||||||
}: ServiceFqdnListProps) {
|
}: ServiceFqdnListProps) {
|
||||||
const fqdns = serviceDisplayFqdns(service)
|
const fqdns = serviceDisplayFqdns(service)
|
||||||
if (fqdns.length === 0) {
|
if (fqdns.length === 0) {
|
||||||
@@ -76,7 +78,12 @@ export function ServiceFqdnList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex min-w-0 items-center gap-1.5', className)}>
|
<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}
|
{first}
|
||||||
</TruncatedText>
|
</TruncatedText>
|
||||||
{extraCount > 0 ? (
|
{extraCount > 0 ? (
|
||||||
@@ -107,3 +114,71 @@ export function ServiceFqdnList({
|
|||||||
</div>
|
</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,
|
applyFiltersToData,
|
||||||
getActiveFilters,
|
getActiveFilters,
|
||||||
} from '@/components/reui-kit/filter-utils'
|
} from '@/components/reui-kit/filter-utils'
|
||||||
import { ServiceGroupUnit } from '@/components/services/service-group-unit'
|
import { ServiceCatalogSection } from '@/components/services/service-catalog-section'
|
||||||
import {
|
import {
|
||||||
SERVICE_TABS,
|
SERVICE_TABS,
|
||||||
createDefaultServiceFilters,
|
createDefaultServiceFilters,
|
||||||
@@ -296,7 +296,7 @@ export function ServicesGroupedCatalog({
|
|||||||
<FrameDescription>
|
<FrameDescription>
|
||||||
{domainLabel
|
{domainLabel
|
||||||
? `Каталог сервисов с привязками к ${domainLabel}`
|
? `Каталог сервисов с привязками к ${domainLabel}`
|
||||||
: 'Группы, общий домен и FQDN сервисов'}
|
: 'Группы для сортировки; у каждого сервиса — общий домен и IP'}
|
||||||
</FrameDescription>
|
</FrameDescription>
|
||||||
</div>
|
</div>
|
||||||
{primaryAction ? (
|
{primaryAction ? (
|
||||||
@@ -370,9 +370,9 @@ export function ServicesGroupedCatalog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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) => (
|
{units.map((unit) => (
|
||||||
<ServiceGroupUnit
|
<ServiceCatalogSection
|
||||||
key={unit.id}
|
key={unit.id}
|
||||||
group={unit.group}
|
group={unit.group}
|
||||||
services={unit.services}
|
services={unit.services}
|
||||||
|
|||||||
@@ -456,7 +456,6 @@ function ServicesPage() {
|
|||||||
board.columns.map((column, index) => ({
|
board.columns.map((column, index) => ({
|
||||||
id: column.id,
|
id: column.id,
|
||||||
title: column.title,
|
title: column.title,
|
||||||
description: column.domain ?? undefined,
|
|
||||||
healthStatus: column.group?.health_status,
|
healthStatus: column.group?.health_status,
|
||||||
healthLatencyMs: column.group?.health_latency_ms,
|
healthLatencyMs: column.group?.health_latency_ms,
|
||||||
dotClassName: GROUP_DOT_COLORS[index % GROUP_DOT_COLORS.length],
|
dotClassName: GROUP_DOT_COLORS[index % GROUP_DOT_COLORS.length],
|
||||||
@@ -494,7 +493,7 @@ function ServicesPage() {
|
|||||||
|
|
||||||
const pageDescription = filteredDomain
|
const pageDescription = filteredDomain
|
||||||
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
|
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
|
||||||
: 'Группы, общий домен и FQDN сервисов'
|
: 'Группы для сортировки; у каждого сервиса — общий домен и IP'
|
||||||
|
|
||||||
const sheets = (
|
const sheets = (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user