From ab4ccbd7a158b6cac572ddc0ebf73a1c58db4abc Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 19 Aug 2026 13:03:52 +0700 Subject: [PATCH] feat(services): add copyable FQDN functionality and improve service display - Enhanced the ServiceFqdnList component to support a copyable feature, allowing users to easily copy FQDNs to the clipboard. - Updated the ServiceKanbanCard to utilize the new copyable functionality in the FQDN list. - Refined the services grouped catalog logic to improve data handling and display. - Removed the obsolete services-grouped-columns file to streamline the codebase. This commit improves user experience by facilitating easier access to FQDNs and optimizing service management components. --- .../components/kanban/service-kanban-card.tsx | 2 +- .../components/services/service-fqdn-list.tsx | 44 +- .../services/service-group-unit.tsx | 392 ++++++++++++++++++ .../services/services-grouped-catalog.tsx | 336 ++++++--------- .../services/services-grouped-columns.tsx | 308 -------------- apps/web/src/routes/_auth/services/index.tsx | 2 +- 6 files changed, 575 insertions(+), 509 deletions(-) create mode 100644 apps/web/src/components/services/service-group-unit.tsx delete mode 100644 apps/web/src/components/services/services-grouped-columns.tsx diff --git a/apps/web/src/components/kanban/service-kanban-card.tsx b/apps/web/src/components/kanban/service-kanban-card.tsx index 0a7a3ff..7103564 100644 --- a/apps/web/src/components/kanban/service-kanban-card.tsx +++ b/apps/web/src/components/kanban/service-kanban-card.tsx @@ -78,7 +78,7 @@ export function ServiceKanbanCard({ - + diff --git a/apps/web/src/components/services/service-fqdn-list.tsx b/apps/web/src/components/services/service-fqdn-list.tsx index 5a1895f..46bd05b 100644 --- a/apps/web/src/components/services/service-fqdn-list.tsx +++ b/apps/web/src/components/services/service-fqdn-list.tsx @@ -1,7 +1,12 @@ +import { CheckIcon, CopyIcon } from 'lucide-react' +import { toast } from 'sonner' + import { Badge } from '@/components/reui/badge' import { TruncatedText } from '@/components/truncated-text' +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { serviceDisplayFqdns } from '@/lib/service-utils' import type { ServiceView } from '@/lib/schemas' +import { Button } from '@cfdm/ui/components/button' import { Tooltip, TooltipContent, @@ -10,16 +15,51 @@ import { } from '@cfdm/ui/components/tooltip' import { cn } from '@cfdm/ui/lib/utils' +export function CopyFqdnButton({ + value, + className, +}: { + value: string + className?: string +}) { + const { isCopied, copyToClipboard } = useCopyToClipboard({ + onCopy: () => toast.success('Скопировано'), + }) + + return ( + + ) +} + interface ServiceFqdnListProps { service: ServiceView className?: string emptyLabel?: string + copyable?: boolean } export function ServiceFqdnList({ service, className, - emptyLabel = 'FQDN не задан', + emptyLabel = 'Нет FQDN', + copyable = false, }: ServiceFqdnListProps) { const fqdns = serviceDisplayFqdns(service) if (fqdns.length === 0) { @@ -32,6 +72,7 @@ export function ServiceFqdnList({ const [first, ...rest] = fqdns const extraCount = rest.length + const copyValue = fqdns.join('\n') return (
@@ -62,6 +103,7 @@ export function ServiceFqdnList({ ) : null} + {copyable ? : null}
) } diff --git a/apps/web/src/components/services/service-group-unit.tsx b/apps/web/src/components/services/service-group-unit.tsx new file mode 100644 index 0000000..e0a85ee --- /dev/null +++ b/apps/web/src/components/services/service-group-unit.tsx @@ -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 = { + 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 ( + + +
+ +
+
+ {title} + + {services.length} + +
+ + {servicesCountLabel(services.length)} + {typeLabel ? ( + <> + +
+
+
+ {group ? ( + + ) : null} + {group ? ( + + + } + > + + + + onAddServiceToGroup(group.id)}> + + Добавить сервис + + onEditGroup(group)}> + Изменить группу + + + onDeleteGroup(group)} + > + Удалить группу + + + + ) : ( + + )} +
+
+ + + {group ? ( + <> + +
+
+ Общий домен + {commonDomain ? ( + + {commonDomain} + + ) : ( + Не задан + )} +
+ {commonDomain ? ( + + ) : null} +
+ + ) : null} + + + +
+

Сервисы

+ {services.length === 0 ? ( +
+ Нет сервисов + +
+ ) : ( +
+ {visible.map((service, index) => ( + 0} + onEditService={onEditService} + onDeleteService={onDeleteService} + onToggleService={onToggleService} + /> + ))} + {rest.length > 0 ? ( + + + {rest.map((service) => ( + + ))} + +
+ + } + > + {moreOpen + ? 'Скрыть' + : `Показать ещё ${rest.length}`} + + +
+
+ ) : null} +
+ )} +
+
+ + ) +} + +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 ? : null} + + + + + {service.name} + + + + + + + + {service.name} + + + + + + + onToggleService(service.id, Boolean(checked)) + } + aria-label={ + service.enabled ? 'Выключить сервис' : 'Включить сервис' + } + /> + + + + } + > + + + + + } + > + Обзор + + onEditService(service)}> + Изменить + + onDeleteService(service)} + > + Удалить + + + + + + + ) +} diff --git a/apps/web/src/components/services/services-grouped-catalog.tsx b/apps/web/src/components/services/services-grouped-catalog.tsx index 93a3b7d..a9d9038 100644 --- a/apps/web/src/components/services/services-grouped-catalog.tsx +++ b/apps/web/src/components/services/services-grouped-catalog.tsx @@ -1,10 +1,4 @@ -import { useEffect, useMemo, useState, type ReactNode } from 'react' -import { - getCoreRowModel, - getExpandedRowModel, - useReactTable, - type ExpandedState, -} from '@tanstack/react-table' +import { useMemo, useState, type ReactNode } from 'react' import { ChevronDownIcon, FilterIcon, @@ -14,24 +8,21 @@ import { ServerIcon, } from 'lucide-react' -import { DataGrid } from '@/components/reui/data-grid/data-grid' -import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' -import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' -import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' import { Filters, type Filter } from '@/components/reui/filters' import { Frame, FrameDescription, - FrameFooter, FrameHeader, FramePanel, FrameTitle, } from '@/components/reui/frame' import { CountedLineTabs } from '@/components/counted-line-tabs' import { EmptyState } from '@/components/empty-state' -import { applyFiltersToData } from '@/components/reui-kit/filter-utils' -import { createServicesGroupedColumns } from '@/components/services/services-grouped-columns' -import type { ServiceCatalogTreeRow } from '@/components/services/services-grouped-columns' +import { + applyFiltersToData, + getActiveFilters, +} from '@/components/reui-kit/filter-utils' +import { ServiceGroupUnit } from '@/components/services/service-group-unit' import { SERVICE_TABS, createDefaultServiceFilters, @@ -98,60 +89,51 @@ function catalogTabFilter(row: ServiceCatalogRow, tabId: string) { return serviceTabFilter(row, tabId) } -function buildTreeRows( +interface GroupUnitData { + id: string + group: ServiceGroupView | null + services: ServiceView[] +} + +function buildGroupUnits( data: ServiceGroupsResponse, filteredServiceIds: Set, - domainId?: number, -): ServiceCatalogTreeRow[] { - const rows: ServiceCatalogTreeRow[] = [] + domainId: number | undefined, + showEmptyGroups: boolean, +): GroupUnitData[] { + const units: GroupUnitData[] = [] for (const group of data.groups) { - const services = group.services - .filter((s) => serviceMatchesDomain(s, domainId)) - .filter((s) => filteredServiceIds.has(s.id)) - if (services.length === 0) continue + const matchingDomain = group.services.filter((service) => + serviceMatchesDomain(service, domainId), + ) + const services = matchingDomain.filter((service) => + filteredServiceIds.has(service.id), + ) - rows.push({ - kind: 'group', - id: `group-${group.id}`, - group, - subRows: services.map((service) => ({ - kind: 'service' as const, - id: `service-${service.id}`, - service, - groupId: group.id, - groupName: group.name, - })), - }) + if (services.length === 0) { + if (showEmptyGroups && matchingDomain.length === 0) { + units.push({ id: `group-${group.id}`, group, services: [] }) + } + continue + } + + units.push({ id: `group-${group.id}`, group, services }) } const ungrouped = data.ungrouped - .filter((s) => serviceMatchesDomain(s, domainId)) - .filter((s) => filteredServiceIds.has(s.id)) + .filter((service) => serviceMatchesDomain(service, domainId)) + .filter((service) => filteredServiceIds.has(service.id)) if (ungrouped.length > 0) { - rows.push({ - kind: 'group', + units.push({ id: 'group-ungrouped', group: null, - subRows: ungrouped.map((service) => ({ - kind: 'service' as const, - id: `service-${service.id}`, - service, - groupId: null, - groupName: null, - })), + services: ungrouped, }) } - return rows -} - -function defaultExpanded(rows: ServiceCatalogTreeRow[]): ExpandedState { - return rows.reduce>((acc, row) => { - acc[row.id] = true - return acc - }, {}) + return units } export function ServicesAddMenu({ @@ -229,7 +211,6 @@ export function ServicesGroupedCatalog({ const [filters, setFilters] = useState(() => createDefaultServiceFilters(), ) - const [expanded, setExpanded] = useState({}) const filterFields = useServiceFilterFields() const flatRows = useMemo(() => { @@ -263,60 +244,25 @@ export function ServicesGroupedCatalog({ return new Set(afterFilters.map((r) => r.id)) }, [flatRows, tab, filters]) - const treeData = useMemo( - () => buildTreeRows(data, filteredIds, domainId), - [data, filteredIds, domainId], + const showEmptyGroups = + tab === 'all' && domainId == null && getActiveFilters(filters).length === 0 + + const units = useMemo( + () => buildGroupUnits(data, filteredIds, domainId, showEmptyGroups), + [data, filteredIds, domainId, showEmptyGroups], ) - const expandedKey = treeData.map((r) => r.id).join(',') - useEffect(() => { - setExpanded(defaultExpanded(treeData)) - }, [expandedKey, treeData]) - - const columns = useMemo( - () => - createServicesGroupedColumns({ - onEditService, - onDeleteService, - onToggleService, - onEditGroup, - onDeleteGroup, - onAddServiceToGroup, - togglingId, - }), - [ - onEditService, - onDeleteService, - onToggleService, - onEditGroup, - onDeleteGroup, - onAddServiceToGroup, - togglingId, - ], - ) - - const table = useReactTable({ - data: treeData, - columns, - state: { expanded }, - onExpandedChange: setExpanded, - getSubRows: (row) => (row.kind === 'group' ? row.subRows : undefined), - getCoreRowModel: getCoreRowModel(), - getExpandedRowModel: getExpandedRowModel(), - getRowId: (row) => row.id, - }) - if (isLoading) { return ( - + - {Array.from({ length: 6 }).map((_, i) => ( - + {Array.from({ length: 3 }).map((_, i) => ( + ))} @@ -342,112 +288,106 @@ export function ServicesGroupedCatalog({ } return ( - - - {!hideHeader ? ( - -
- Сервисы - - {domainLabel - ? `Каталог сервисов с привязками к ${domainLabel}` - : 'Группы, FQDN и доступность сервисов'} - -
- {primaryAction ? ( -
- {primaryAction} -
- ) : null} -
- ) : null} - - -
- ({ - id: t.id, - label: t.label, - count: tabCounts[t.id] ?? 0, - }))} - value={tab} - onValueChange={setTab} - /> + + {!hideHeader ? ( + +
+ Сервисы + + {domainLabel + ? `Каталог сервисов с привязками к ${domainLabel}` + : 'Группы, общий домен и FQDN сервисов'} +
+ {primaryAction ? ( +
+ {primaryAction} +
+ ) : null} +
+ ) : null} - + +
+ ({ + id: t.id, + label: t.label, + count: tabCounts[t.id] ?? 0, + }))} + value={tab} + onValueChange={setTab} + /> +
-
- - - Фильтры + + +
+ + + Фильтры + + } + /> + +
+ + + + {units.length === 0 ? ( +
+ { + setTab('all') + setFilters(createDefaultServiceFilters()) + }} + > + Сбросить } /> -
- - - - {treeData.length === 0 ? ( -
- { - setTab('all') - setFilters(createDefaultServiceFilters()) - }} - > - Сбросить - - } + ) : ( +
+ {units.map((unit) => ( + -
- ) : ( - <> - - - - - - - - - )} - - - + ))} +
+ )} + + ) } diff --git a/apps/web/src/components/services/services-grouped-columns.tsx b/apps/web/src/components/services/services-grouped-columns.tsx deleted file mode 100644 index 36f88a8..0000000 --- a/apps/web/src/components/services/services-grouped-columns.tsx +++ /dev/null @@ -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[] { - return [ - { - id: 'name', - accessorFn: (row) => - isServiceRow(row) ? row.service.name : (row.group?.name ?? 'Без группы'), - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const original = row.original - if (!isServiceRow(original)) { - const title = original.group?.name ?? 'Без группы' - const domain = original.group?.domain - return ( -
- - -
-
- {title} - - {original.subRows.length} - -
- {domain ? ( - - {domain} - - ) : null} -
-
- ) - } - - return ( -
- - {original.service.name} - - -
- ) - }, - enableSorting: false, - minSize: 260, - meta: { headerTitle: 'Группа / сервис', autoSize: true }, - }, - { - id: 'health', - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const original = row.original - if (!isServiceRow(original)) { - return ( - - ) - } - return ( - - ) - }, - size: 120, - enableSorting: false, - }, - { - id: 'enabled', - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const original = row.original - if (!isServiceRow(original)) return null - return ( -
- - onToggleService(original.service.id, Boolean(checked)) - } - aria-label={ - original.service.enabled - ? 'Выключить сервис' - : 'Включить сервис' - } - /> - -
- ) - }, - size: 140, - enableSorting: false, - }, - { - id: 'actions', - enableSorting: false, - header: () => Действия, - cell: ({ row }) => { - const original = row.original - if (!isServiceRow(original)) { - if (!original.group) { - return ( - - ) - } - return ( - - - } - > - - - - onAddServiceToGroup(original.group!.id)} - > - - Добавить сервис - - onEditGroup(original.group!)}> - Изменить группу - - - onDeleteGroup(original.group!)} - > - Удалить группу - - - - ) - } - - return ( - - - } - > - - - - - } - > - Обзор - - onEditService(original.service)}> - Изменить - - onDeleteService(original.service)} - > - Удалить - - - - ) - }, - size: 56, - }, - ] -} - -export function useServicesGroupedColumns( - args: Parameters[0], -) { - return useMemo(() => createServicesGroupedColumns(args), [ - args.onEditService, - args.onDeleteService, - args.onToggleService, - args.onEditGroup, - args.onDeleteGroup, - args.onAddServiceToGroup, - args.togglingId, - ]) -} diff --git a/apps/web/src/routes/_auth/services/index.tsx b/apps/web/src/routes/_auth/services/index.tsx index ccc8a43..639ae16 100644 --- a/apps/web/src/routes/_auth/services/index.tsx +++ b/apps/web/src/routes/_auth/services/index.tsx @@ -494,7 +494,7 @@ function ServicesPage() { const pageDescription = filteredDomain ? `Сервисы с привязками к домену ${filteredDomain.zone_name}` - : 'Группы, FQDN и доступность сервисов' + : 'Группы, общий домен и FQDN сервисов' const sheets = ( <>