Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab4ccbd7a1 |
@@ -78,7 +78,7 @@ export function ServiceKanbanCard({
|
|||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
|
|
||||||
<ItemContent className="min-w-0 gap-2">
|
<ItemContent className="min-w-0 gap-2">
|
||||||
<ServiceFqdnList service={service} />
|
<ServiceFqdnList copyable service={service} />
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
||||||
<ItemFooter className="min-w-0 justify-between gap-2">
|
<ItemFooter className="min-w-0 justify-between gap-2">
|
||||||
|
|||||||
@@ -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,51 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServiceFqdnList({
|
export function ServiceFqdnList({
|
||||||
service,
|
service,
|
||||||
className,
|
className,
|
||||||
emptyLabel = 'FQDN не задан',
|
emptyLabel = 'Нет FQDN',
|
||||||
|
copyable = false,
|
||||||
}: ServiceFqdnListProps) {
|
}: ServiceFqdnListProps) {
|
||||||
const fqdns = serviceDisplayFqdns(service)
|
const fqdns = serviceDisplayFqdns(service)
|
||||||
if (fqdns.length === 0) {
|
if (fqdns.length === 0) {
|
||||||
@@ -32,6 +72,7 @@ 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)}>
|
||||||
@@ -62,6 +103,7 @@ export function ServiceFqdnList({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
) : null}
|
) : null}
|
||||||
|
{copyable ? <CopyFqdnButton value={copyValue} /> : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { ServiceGroupUnit } from '@/components/services/service-group-unit'
|
||||||
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,12 +288,6 @@ export function ServicesGroupedCatalog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGrid
|
|
||||||
table={table}
|
|
||||||
recordCount={filteredIds.size}
|
|
||||||
emptyMessage="Нет записей по выбранным фильтрам."
|
|
||||||
tableLayout={{ dense: true }}
|
|
||||||
>
|
|
||||||
<Frame dense spacing="sm" className="w-full">
|
<Frame dense spacing="sm" className="w-full">
|
||||||
{!hideHeader ? (
|
{!hideHeader ? (
|
||||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||||
@@ -356,7 +296,7 @@ export function ServicesGroupedCatalog({
|
|||||||
<FrameDescription>
|
<FrameDescription>
|
||||||
{domainLabel
|
{domainLabel
|
||||||
? `Каталог сервисов с привязками к ${domainLabel}`
|
? `Каталог сервисов с привязками к ${domainLabel}`
|
||||||
: 'Группы, FQDN и доступность сервисов'}
|
: 'Группы, общий домен и FQDN сервисов'}
|
||||||
</FrameDescription>
|
</FrameDescription>
|
||||||
</div>
|
</div>
|
||||||
{primaryAction ? (
|
{primaryAction ? (
|
||||||
@@ -410,7 +350,7 @@ export function ServicesGroupedCatalog({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{treeData.length === 0 ? (
|
{units.length === 0 ? (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Нет совпадений"
|
title="Нет совпадений"
|
||||||
@@ -430,24 +370,24 @@ export function ServicesGroupedCatalog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex flex-col gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||||
<DataGridScrollArea>
|
{units.map((unit) => (
|
||||||
<DataGridTable />
|
<ServiceGroupUnit
|
||||||
</DataGridScrollArea>
|
key={unit.id}
|
||||||
<Separator />
|
group={unit.group}
|
||||||
<FrameFooter>
|
services={unit.services}
|
||||||
<DataGridPagination
|
togglingId={togglingId}
|
||||||
sizes={[5, 10, 20, 50]}
|
onEditService={onEditService}
|
||||||
rowsPerPageLabel="Строк на странице"
|
onDeleteService={onDeleteService}
|
||||||
info="{from} - {to} of {count}"
|
onToggleService={onToggleService}
|
||||||
previousPageLabel="Предыдущая"
|
onEditGroup={onEditGroup}
|
||||||
nextPageLabel="Следующая"
|
onDeleteGroup={onDeleteGroup}
|
||||||
|
onAddServiceToGroup={onAddServiceToGroup}
|
||||||
/>
|
/>
|
||||||
</FrameFooter>
|
))}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</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,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
@@ -494,7 +494,7 @@ function ServicesPage() {
|
|||||||
|
|
||||||
const pageDescription = filteredDomain
|
const pageDescription = filteredDomain
|
||||||
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
|
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
|
||||||
: 'Группы, FQDN и доступность сервисов'
|
: 'Группы, общий домен и FQDN сервисов'
|
||||||
|
|
||||||
const sheets = (
|
const sheets = (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user