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