feat(web): enhance UI components and improve documentation
Build, Test, and Push CFDM Docker Image / test (push) Failing after 37s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Build, Test, and Push CFDM Docker Image / test (push) Failing after 37s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
- Updated frontend UI patterns documentation to reflect new components and their usage. - Enhanced CertKpiCards for better KPI visualization and streamlined data handling. - Refactored OpsDashboard and ResourcePage for improved layout consistency and loading states. - Introduced CatalogBoardToggle for better navigation between views in the Groups and Services pages. - Improved Tabs component styles for better responsiveness and accessibility. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -127,7 +127,7 @@ function DomainsPage() {
|
||||
const primaryAction = (
|
||||
<>
|
||||
<Button type="button" variant="outline" nativeButton={false} render={<Link to="/groups" />}>
|
||||
Канбан групп
|
||||
Группы
|
||||
</Button>
|
||||
<Button type="button" onClick={() => setSheetOpen(true)}>
|
||||
Импортировать домен
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { FolderTreeIcon } from 'lucide-react'
|
||||
@@ -17,11 +17,22 @@ import { PageShell } from '@/components/page-shell'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { DomainGroupEditSheet } from '@/components/domain-group-edit-sheet'
|
||||
import { KanbanBoard, KanbanBoardSkeleton } from '@/components/reui-kit'
|
||||
import { CatalogBoardToggle } from '@/components/catalog-board-toggle'
|
||||
import {
|
||||
createDefaultGroupFilters,
|
||||
GROUP_TABS,
|
||||
groupFilterFieldValue,
|
||||
groupTabFilter,
|
||||
useGroupColumns,
|
||||
useGroupFilterFields,
|
||||
type GroupCatalogRow,
|
||||
} from '@/components/columns/groups-columns'
|
||||
import { ResourcePage, KanbanBoard, KanbanBoardSkeleton } from '@/components/reui-kit'
|
||||
import { DomainKanbanCard } from '@/components/kanban/domain-kanban-card'
|
||||
import { useDomainGroupsKanban } from '@/hooks/use-domain-groups-kanban'
|
||||
import { groupColumnId } from '@/components/board/column-ids'
|
||||
import { UNGROUPED_COLUMN_ID } from '@/components/groups-board/column-ids'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
const GROUP_DOT_COLORS = [
|
||||
@@ -36,6 +47,8 @@ const GROUP_DOT_COLORS = [
|
||||
]
|
||||
|
||||
export const Route = createFileRoute('/_auth/groups')({
|
||||
validateSearch: (search: Record<string, unknown>): { view?: 'board' } =>
|
||||
search.view === 'board' ? { view: 'board' } : {},
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
Promise.all([
|
||||
queryClient.ensureQueryData(groupsQueryOptions()),
|
||||
@@ -46,9 +59,13 @@ export const Route = createFileRoute('/_auth/groups')({
|
||||
})
|
||||
|
||||
function GroupsPage() {
|
||||
const { view: viewParam } = Route.useSearch()
|
||||
const view = viewParam === 'board' ? 'board' : 'catalog'
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const [createSheetOpen, setCreateSheetOpen] = useState(false)
|
||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
|
||||
const [deletingGroup, setDeletingGroup] = useState<Group | null>(null)
|
||||
const [filters, setFilters] = useState<Filter[]>(() => createDefaultGroupFilters())
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const {
|
||||
@@ -66,6 +83,32 @@ function GroupsPage() {
|
||||
domains,
|
||||
})
|
||||
|
||||
const catalogRows = useMemo<GroupCatalogRow[]>(() => {
|
||||
return (groups ?? []).map((group) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
slug: group.slug,
|
||||
domainCount: (domains ?? []).filter((d) => d.group_id === group.id).length,
|
||||
}))
|
||||
}, [groups, domains])
|
||||
|
||||
const groupById = useMemo(() => {
|
||||
const map = new Map((groups ?? []).map((g) => [g.id, g]))
|
||||
return map
|
||||
}, [groups])
|
||||
|
||||
const filterFields = useGroupFilterFields()
|
||||
const { columns } = useGroupColumns({
|
||||
onEdit: (row) => {
|
||||
const group = groupById.get(row.id)
|
||||
if (group) setEditingGroup(group)
|
||||
},
|
||||
onDelete: (row) => {
|
||||
const group = groupById.get(row.id)
|
||||
if (group) setDeletingGroup(group)
|
||||
},
|
||||
})
|
||||
|
||||
const serviceLabelsByDomain = useMemo(() => {
|
||||
const map = new Map<number, string[]>()
|
||||
for (const binding of bindings ?? []) {
|
||||
@@ -98,11 +141,14 @@ function GroupsPage() {
|
||||
]
|
||||
}, [groups])
|
||||
|
||||
const isEmpty = useMemo(() => {
|
||||
if (!groups || !domains) return true
|
||||
if (domains.length > 0) return false
|
||||
return groups.length === 0
|
||||
}, [groups, domains])
|
||||
function setView(next: 'catalog' | 'board') {
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
view: next === 'board' ? 'board' : undefined,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function invalidateAll() {
|
||||
queryClient.invalidateQueries({ queryKey: groupKeys.all })
|
||||
@@ -147,10 +193,31 @@ function GroupsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const createButton = (
|
||||
<Button type="button" onClick={() => setCreateSheetOpen(true)}>
|
||||
Создать группу
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageShell>
|
||||
<KanbanBoardSkeleton columns={3} />
|
||||
{view === 'board' ? (
|
||||
<KanbanBoardSkeleton columns={3} />
|
||||
) : (
|
||||
<ResourcePage
|
||||
title="Группы"
|
||||
description="Каталог групп доменов"
|
||||
isLoading
|
||||
filterFields={[]}
|
||||
filters={[]}
|
||||
onFiltersChange={() => {}}
|
||||
getFilterFieldValue={() => ''}
|
||||
columns={[]}
|
||||
data={[]}
|
||||
getRowId={() => ''}
|
||||
/>
|
||||
)}
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -173,36 +240,66 @@ function GroupsPage() {
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
{isEmpty ? (
|
||||
<EmptyState
|
||||
icon={FolderTreeIcon}
|
||||
title="Группы не найдены"
|
||||
description="Создайте группу и назначьте домены при импорте или перетаскиванием на доске."
|
||||
action={
|
||||
<Button type="button" onClick={() => setCreateSheetOpen(true)}>
|
||||
Добавить группу
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{view === 'board' ? (
|
||||
(groups?.length ?? 0) === 0 && (domains?.length ?? 0) === 0 ? (
|
||||
<EmptyState
|
||||
icon={FolderTreeIcon}
|
||||
title="Группы не найдены"
|
||||
description="Создайте группу, чтобы организовать домены."
|
||||
action={
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{createButton}
|
||||
<CatalogBoardToggle view="board" onViewChange={setView} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<KanbanBoard
|
||||
title="Распределение доменов"
|
||||
description="Перетащите домен в группу или создайте новую группу"
|
||||
columns={columnConfigs}
|
||||
value={kanbanValue}
|
||||
onValueChange={handleKanbanValueChange}
|
||||
toolbarActions={
|
||||
<>
|
||||
{createButton}
|
||||
<CatalogBoardToggle view="board" onViewChange={setView} />
|
||||
</>
|
||||
}
|
||||
renderCard={(domain, ctx) => (
|
||||
<DomainKanbanCard
|
||||
domain={domain}
|
||||
serviceLabels={serviceLabelsByDomain.get(domain.id)}
|
||||
isOverlay={ctx.isOverlay}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<KanbanBoard
|
||||
title="Группы доменов"
|
||||
description="Перетащите домен в группу или создайте новую группу для организации зон"
|
||||
columns={columnConfigs}
|
||||
value={kanbanValue}
|
||||
onValueChange={handleKanbanValueChange}
|
||||
toolbarActions={
|
||||
<Button type="button" onClick={() => setCreateSheetOpen(true)}>
|
||||
Добавить группу
|
||||
</Button>
|
||||
<ResourcePage
|
||||
title="Группы"
|
||||
description="Каталог групп доменов"
|
||||
tabs={GROUP_TABS.map((tab) => ({ ...tab }))}
|
||||
tabFilter={groupTabFilter}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters(createDefaultGroupFilters())}
|
||||
getFilterFieldValue={groupFilterFieldValue}
|
||||
columns={columns}
|
||||
data={catalogRows}
|
||||
getRowId={(row) => String(row.id)}
|
||||
primaryAction={
|
||||
<>
|
||||
{createButton}
|
||||
<CatalogBoardToggle view="catalog" onViewChange={setView} />
|
||||
</>
|
||||
}
|
||||
renderCard={(domain, ctx) => (
|
||||
<DomainKanbanCard
|
||||
domain={domain}
|
||||
serviceLabels={serviceLabelsByDomain.get(domain.id)}
|
||||
isOverlay={ctx.isOverlay}
|
||||
/>
|
||||
)}
|
||||
emptyState={{
|
||||
title: 'Группы не найдены',
|
||||
description: 'Создайте группу для организации доменов.',
|
||||
action: createButton,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,9 +4,6 @@ import { useMemo, useState, useEffect } from 'react'
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
FolderTreeIcon,
|
||||
GlobeIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
certificatesQueryOptions,
|
||||
@@ -20,7 +17,7 @@ import {
|
||||
CertStatusChart,
|
||||
GroupDomainsChart,
|
||||
OpsDashboard,
|
||||
OpsDashboardHintLink,
|
||||
type KpiStatCard,
|
||||
} from '@/components/reui-kit'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
@@ -44,8 +41,6 @@ export const Route = createFileRoute('/_auth/')({
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
const KPI_ICON_BG = ['bg-chart-1', 'bg-chart-2', 'bg-chart-3', 'bg-chart-4']
|
||||
|
||||
function countByStatus(summary: [string, number][] | undefined, statuses: string[]) {
|
||||
if (!summary) return 0
|
||||
return summary
|
||||
@@ -64,9 +59,6 @@ function DashboardPage() {
|
||||
(serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0) +
|
||||
(serviceData?.ungrouped.length ?? 0)
|
||||
|
||||
const groupedServiceCount =
|
||||
serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0
|
||||
|
||||
const isLoading = domainsLoading || summaryLoading
|
||||
|
||||
const statusChartData = useMemo(
|
||||
@@ -129,77 +121,63 @@ function DashboardPage() {
|
||||
)
|
||||
}, [isLoading, summary, statusChartData, groupChartData, domains, groups])
|
||||
|
||||
const kpiCards = [
|
||||
{
|
||||
id: 'domains',
|
||||
typeLabel: 'Инфраструктура',
|
||||
title: 'Домены',
|
||||
metricLabel: 'Импортированные зоны',
|
||||
value: domains?.length ?? 0,
|
||||
detail:
|
||||
ungroupedCount > 0
|
||||
? `${ungroupedCount} без группы`
|
||||
: 'Все домены распределены по группам',
|
||||
icon: <GlobeIcon aria-hidden="true" />,
|
||||
iconBg: KPI_ICON_BG[0],
|
||||
hint: <OpsDashboardHintLink to="/domains">Управление</OpsDashboardHintLink>,
|
||||
},
|
||||
{
|
||||
id: 'groups',
|
||||
typeLabel: 'Организация',
|
||||
title: 'Группы',
|
||||
metricLabel: 'Группы доменов',
|
||||
value: groups?.length ?? 0,
|
||||
detail: `${groupChartData.filter((g) => g.count > 0).length} с активными зонами`,
|
||||
icon: <FolderTreeIcon aria-hidden="true" />,
|
||||
iconBg: KPI_ICON_BG[1],
|
||||
hint: <OpsDashboardHintLink to="/groups">Канбан</OpsDashboardHintLink>,
|
||||
},
|
||||
{
|
||||
id: 'services',
|
||||
typeLabel: 'Операции',
|
||||
title: 'Сервисы',
|
||||
metricLabel: 'Привязки и сервисы',
|
||||
value: serviceCount,
|
||||
detail:
|
||||
serviceData?.ungrouped.length
|
||||
? `${serviceData.ungrouped.length} без группы · ${groupedServiceCount} в группах`
|
||||
: `${groupedServiceCount} в группах`,
|
||||
icon: <ServerIcon aria-hidden="true" />,
|
||||
iconBg: KPI_ICON_BG[2],
|
||||
hint: (
|
||||
<OpsDashboardHintLink to="/services" search={{ domainId: undefined }}>
|
||||
Управление
|
||||
</OpsDashboardHintLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'certs',
|
||||
typeLabel: 'Безопасность',
|
||||
title: 'Сертификаты',
|
||||
metricLabel: 'Мониторинг TLS',
|
||||
value: certs?.length ?? 0,
|
||||
detail:
|
||||
certWarnings > 0
|
||||
? `${certOk} в норме · ${certWarnings} требуют внимания`
|
||||
: `${certOk} в норме`,
|
||||
icon: <ShieldCheckIcon aria-hidden="true" />,
|
||||
iconBg: KPI_ICON_BG[3],
|
||||
hint: <OpsDashboardHintLink to="/certificates">Мониторинг</OpsDashboardHintLink>,
|
||||
},
|
||||
]
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="text-muted-foreground text-sm">Загрузка панели…</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
const kpiCards = useMemo<KpiStatCard[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'domains',
|
||||
label: 'Домены',
|
||||
value: domains?.length ?? 0,
|
||||
hint:
|
||||
ungroupedCount > 0
|
||||
? `${ungroupedCount} без группы`
|
||||
: 'Все в группах',
|
||||
to: '/domains',
|
||||
},
|
||||
{
|
||||
id: 'groups',
|
||||
label: 'Группы',
|
||||
value: groups?.length ?? 0,
|
||||
hint: `${groupChartData.filter((g) => g.count > 0).length} с зонами`,
|
||||
to: '/groups',
|
||||
},
|
||||
{
|
||||
id: 'services',
|
||||
label: 'Сервисы',
|
||||
value: serviceCount,
|
||||
hint: serviceData?.ungrouped.length
|
||||
? `${serviceData.ungrouped.length} без группы`
|
||||
: undefined,
|
||||
to: '/services',
|
||||
search: { domainId: undefined },
|
||||
},
|
||||
{
|
||||
id: 'certs',
|
||||
label: 'Сертификаты',
|
||||
value: certs?.length ?? 0,
|
||||
hint:
|
||||
certWarnings > 0
|
||||
? `${certOk} в норме · ${certWarnings} внимания`
|
||||
: `${certOk} в норме`,
|
||||
to: '/certificates',
|
||||
},
|
||||
],
|
||||
[
|
||||
domains?.length,
|
||||
ungroupedCount,
|
||||
groups?.length,
|
||||
groupChartData,
|
||||
serviceCount,
|
||||
serviceData?.ungrouped.length,
|
||||
certs?.length,
|
||||
certWarnings,
|
||||
certOk,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<OpsDashboard
|
||||
isLoading={isLoading}
|
||||
kpiCards={kpiCards}
|
||||
charts={
|
||||
<>
|
||||
@@ -208,7 +186,7 @@ function DashboardPage() {
|
||||
</>
|
||||
}
|
||||
queue={
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangleIcon
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ServerIcon } from 'lucide-react'
|
||||
@@ -26,18 +26,35 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
||||
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
|
||||
import { ServicesBulkToolbar } from '@/components/services-board/services-bulk-toolbar'
|
||||
import { CatalogBoardToggle } from '@/components/catalog-board-toggle'
|
||||
import {
|
||||
createDefaultServiceFilters,
|
||||
SERVICE_TABS,
|
||||
serviceFilterFieldValue,
|
||||
serviceTabFilter,
|
||||
useServiceColumns,
|
||||
useServiceFilterFields,
|
||||
type ServiceCatalogRow,
|
||||
} from '@/components/columns/services-columns'
|
||||
import { useServicesKanban } from '@/hooks/use-services-kanban'
|
||||
import { useServicesSelection } from '@/hooks/use-services-selection'
|
||||
import { KanbanBoard, KanbanBoardSkeleton } from '@/components/reui-kit'
|
||||
import {
|
||||
ResourcePage,
|
||||
KanbanBoard,
|
||||
KanbanBoardSkeleton,
|
||||
} from '@/components/reui-kit'
|
||||
import { ServiceKanbanCard } from '@/components/kanban/service-kanban-card'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
domainId:
|
||||
search.domainId != null && search.domainId !== ''
|
||||
? Number(search.domainId)
|
||||
: undefined,
|
||||
validateSearch: (
|
||||
search: Record<string, unknown>,
|
||||
): { domainId?: number; view?: 'board' } => ({
|
||||
...(search.domainId != null && search.domainId !== ''
|
||||
? { domainId: Number(search.domainId) }
|
||||
: {}),
|
||||
...(search.view === 'board' ? { view: 'board' as const } : {}),
|
||||
}),
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
Promise.all([
|
||||
@@ -76,8 +93,53 @@ const GROUP_DOT_COLORS = [
|
||||
'bg-destructive',
|
||||
]
|
||||
|
||||
function flattenServices(
|
||||
data: ServiceGroupsResponse | undefined,
|
||||
domainId?: number,
|
||||
): ServiceCatalogRow[] {
|
||||
if (!data) return []
|
||||
|
||||
const rows: ServiceCatalogRow[] = []
|
||||
|
||||
for (const group of data.groups) {
|
||||
for (const service of group.services) {
|
||||
const domainIds = service.domains.map((d) => d.domain_id)
|
||||
if (domainId != null && !domainIds.includes(domainId)) continue
|
||||
rows.push({
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
slug: service.slug,
|
||||
enabled: service.enabled,
|
||||
groupId: group.id,
|
||||
groupName: group.name,
|
||||
domainIds,
|
||||
service,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const service of data.ungrouped) {
|
||||
const domainIds = service.domains.map((d) => d.domain_id)
|
||||
if (domainId != null && !domainIds.includes(domainId)) continue
|
||||
rows.push({
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
slug: service.slug,
|
||||
enabled: service.enabled,
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
domainIds,
|
||||
service,
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
function ServicesPage() {
|
||||
const { domainId } = Route.useSearch()
|
||||
const { domainId, view: viewParam } = Route.useSearch()
|
||||
const view = viewParam === 'board' ? 'board' : 'catalog'
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const [createSheetOpen, setCreateSheetOpen] = useState(false)
|
||||
const [createGroupSheetOpen, setCreateGroupSheetOpen] = useState(false)
|
||||
const [defaultGroupId, setDefaultGroupId] = useState<number | null>(null)
|
||||
@@ -90,6 +152,7 @@ function ServicesPage() {
|
||||
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
|
||||
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
|
||||
const [bulkToggling, setBulkToggling] = useState(false)
|
||||
const [filters, setFilters] = useState<Filter[]>(() => createDefaultServiceFilters())
|
||||
const queryClient = useQueryClient()
|
||||
const selection = useServicesSelection()
|
||||
const {
|
||||
@@ -118,21 +181,21 @@ function ServicesPage() {
|
||||
: undefined
|
||||
|
||||
const groups = useMemo(() => data?.groups ?? [], [data?.groups])
|
||||
const catalogRows = useMemo(
|
||||
() => flattenServices(data, domainId),
|
||||
[data, domainId],
|
||||
)
|
||||
|
||||
const isEmpty = useMemo(() => {
|
||||
if (!data) return true
|
||||
if (domainId) {
|
||||
return (
|
||||
board.columns.length === 0 ||
|
||||
board.columns.every((column) => column.items.length === 0)
|
||||
)
|
||||
}
|
||||
const hasServices =
|
||||
data.groups.some((group) => group.services.length > 0) ||
|
||||
data.ungrouped.length > 0
|
||||
if (hasServices) return false
|
||||
return data.groups.length === 0
|
||||
}, [data, domainId, board.columns])
|
||||
const filterFields = useServiceFilterFields()
|
||||
|
||||
function setView(next: 'catalog' | 'board') {
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
view: next === 'board' ? 'board' : undefined,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function invalidateAll() {
|
||||
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all })
|
||||
@@ -303,6 +366,13 @@ function ServicesPage() {
|
||||
deleteGroupMutation.mutate(id)
|
||||
}
|
||||
|
||||
const { columns } = useServiceColumns({
|
||||
onEdit: setEditingService,
|
||||
onDelete: setDeletingService,
|
||||
onToggle: handleServiceToggle,
|
||||
togglingId: togglingServiceId,
|
||||
})
|
||||
|
||||
async function handleBulkToggle(enabled: boolean) {
|
||||
const ids = Array.from(selection.selectedIds)
|
||||
if (ids.length === 0) return
|
||||
@@ -353,7 +423,7 @@ function ServicesPage() {
|
||||
title: column.title,
|
||||
description: column.domain ?? undefined,
|
||||
dotClassName: GROUP_DOT_COLORS[index % GROUP_DOT_COLORS.length],
|
||||
addLabel: column.groupId ? 'Добавить сервис' : 'Добавить сервис',
|
||||
addLabel: 'Добавить сервис',
|
||||
onAdd: () => handleOpenCreateService(column.groupId),
|
||||
})),
|
||||
[board.columns],
|
||||
@@ -363,79 +433,19 @@ function ServicesPage() {
|
||||
? 'Перетаскивание отключено при фильтре по домену'
|
||||
: 'Перетащите сервис между группами или измените порядок в списке'
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<ServicesBulkToolbar
|
||||
count={selection.count}
|
||||
isPending={bulkToggling}
|
||||
onEnable={() => handleBulkToggle(true)}
|
||||
onDisable={() => handleBulkToggle(false)}
|
||||
onClear={selection.clear}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<KanbanBoardSkeleton columns={3} />
|
||||
) : isError ? (
|
||||
<EmptyState
|
||||
title="Не удалось загрузить сервисы"
|
||||
description={error?.message}
|
||||
action={
|
||||
<Button type="button" variant="outline" onClick={() => refetch()}>
|
||||
Повторить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : isEmpty ? (
|
||||
<EmptyState
|
||||
icon={ServerIcon}
|
||||
title="Сервисы не найдены"
|
||||
description={
|
||||
filteredDomain
|
||||
? `Нет сервисов с привязками к ${filteredDomain.zone_name}`
|
||||
: 'Создайте группу и сервис — например VPN Panel или Home Assistant.'
|
||||
}
|
||||
action={
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
||||
Добавить группу
|
||||
</Button>
|
||||
<Button onClick={() => handleOpenCreateService()}>Добавить сервис</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<KanbanBoard
|
||||
title="Сервисы"
|
||||
description={
|
||||
filteredDomain
|
||||
? `Сервисы с привязками к домену ${filteredDomain.zone_name}. ${boardHint}`
|
||||
: `Сервисы и группы — FQDN синхронизируются в Cloudflare при включении. ${boardHint}`
|
||||
}
|
||||
columns={columnConfigs}
|
||||
value={kanbanValue}
|
||||
onValueChange={dragDisabled ? () => {} : handleKanbanValueChange}
|
||||
toolbarActions={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
||||
Добавить группу
|
||||
</Button>
|
||||
<Button onClick={() => handleOpenCreateService()}>Добавить сервис</Button>
|
||||
</>
|
||||
}
|
||||
renderCard={(service, ctx) => (
|
||||
<ServiceKanbanCard
|
||||
service={service}
|
||||
isOverlay={ctx.isOverlay}
|
||||
dragDisabled={dragDisabled}
|
||||
isToggling={togglingServiceId === service.id}
|
||||
onToggle={handleServiceToggle}
|
||||
onEdit={setEditingService}
|
||||
onDelete={setDeletingService}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
const createServiceButton = (
|
||||
<Button type="button" onClick={() => handleOpenCreateService()}>
|
||||
Добавить сервис
|
||||
</Button>
|
||||
)
|
||||
const createGroupButton = (
|
||||
<Button type="button" variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
||||
Добавить группу
|
||||
</Button>
|
||||
)
|
||||
|
||||
const sheets = (
|
||||
<>
|
||||
<ServiceEditSheet
|
||||
mode="edit"
|
||||
service={editingService}
|
||||
@@ -519,6 +529,150 @@ function ServicesPage() {
|
||||
}}
|
||||
disabled={deleteGroupMutation.isPending || deletingGroupId === deletingGroup?.id}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageShell>
|
||||
{view === 'board' ? (
|
||||
<KanbanBoardSkeleton columns={3} />
|
||||
) : (
|
||||
<ResourcePage
|
||||
title="Сервисы"
|
||||
description="Каталог сервисов"
|
||||
isLoading
|
||||
filterFields={[]}
|
||||
filters={[]}
|
||||
onFiltersChange={() => {}}
|
||||
getFilterFieldValue={() => ''}
|
||||
columns={[]}
|
||||
data={[]}
|
||||
getRowId={() => ''}
|
||||
/>
|
||||
)}
|
||||
{sheets}
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<PageShell>
|
||||
<EmptyState
|
||||
title="Не удалось загрузить сервисы"
|
||||
description={error?.message}
|
||||
action={
|
||||
<Button type="button" variant="outline" onClick={() => refetch()}>
|
||||
Повторить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{sheets}
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
{view === 'board' ? (
|
||||
<>
|
||||
<ServicesBulkToolbar
|
||||
count={selection.count}
|
||||
isPending={bulkToggling}
|
||||
onEnable={() => handleBulkToggle(true)}
|
||||
onDisable={() => handleBulkToggle(false)}
|
||||
onClear={selection.clear}
|
||||
/>
|
||||
{catalogRows.length === 0 && (data?.groups.length ?? 0) === 0 ? (
|
||||
<EmptyState
|
||||
icon={ServerIcon}
|
||||
title="Сервисы не найдены"
|
||||
description={
|
||||
filteredDomain
|
||||
? `Нет сервисов с привязками к ${filteredDomain.zone_name}`
|
||||
: 'Создайте сервис — например VPN Panel или Home Assistant.'
|
||||
}
|
||||
action={
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{createServiceButton}
|
||||
{createGroupButton}
|
||||
<CatalogBoardToggle view="board" onViewChange={setView} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<KanbanBoard
|
||||
title="Распределение сервисов"
|
||||
description={
|
||||
filteredDomain
|
||||
? `Сервисы с привязками к домену ${filteredDomain.zone_name}. ${boardHint}`
|
||||
: `Сервисы и группы — FQDN синхронизируются в Cloudflare при включении. ${boardHint}`
|
||||
}
|
||||
columns={columnConfigs}
|
||||
value={kanbanValue}
|
||||
onValueChange={dragDisabled ? () => {} : handleKanbanValueChange}
|
||||
toolbarActions={
|
||||
<>
|
||||
{createServiceButton}
|
||||
{createGroupButton}
|
||||
<CatalogBoardToggle view="board" onViewChange={setView} />
|
||||
</>
|
||||
}
|
||||
renderCard={(service, ctx) => (
|
||||
<ServiceKanbanCard
|
||||
service={service}
|
||||
isOverlay={ctx.isOverlay}
|
||||
dragDisabled={dragDisabled}
|
||||
isToggling={togglingServiceId === service.id}
|
||||
onToggle={handleServiceToggle}
|
||||
onEdit={setEditingService}
|
||||
onDelete={setDeletingService}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<ResourcePage
|
||||
title="Сервисы"
|
||||
description={
|
||||
filteredDomain
|
||||
? `Каталог сервисов с привязками к ${filteredDomain.zone_name}`
|
||||
: 'Каталог сервисов и привязок'
|
||||
}
|
||||
tabs={SERVICE_TABS.map((tab) => ({ ...tab }))}
|
||||
tabFilter={serviceTabFilter}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters(createDefaultServiceFilters())}
|
||||
getFilterFieldValue={serviceFilterFieldValue}
|
||||
columns={columns}
|
||||
data={catalogRows}
|
||||
getRowId={(row) => String(row.id)}
|
||||
primaryAction={
|
||||
<>
|
||||
{createServiceButton}
|
||||
{createGroupButton}
|
||||
<CatalogBoardToggle view="catalog" onViewChange={setView} />
|
||||
</>
|
||||
}
|
||||
emptyState={{
|
||||
title: 'Сервисы не найдены',
|
||||
description: filteredDomain
|
||||
? `Нет сервисов с привязками к ${filteredDomain.zone_name}`
|
||||
: 'Создайте сервис или группу сервисов.',
|
||||
action: (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{createServiceButton}
|
||||
{createGroupButton}
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{sheets}
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user