From 5d1102b49739e7965be00c9970680333f685d727 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 9 Jul 2026 01:41:09 +0700 Subject: [PATCH] feat(firewall): refactor FirewallPage to use new grid components and improve loading states Updated the FirewallPage component to replace the existing table implementations with FirewallClientsGrid and FirewallRulesGrid for better performance and user experience. Integrated QueryState for handling loading and error states, enhancing the UI responsiveness. Removed deprecated ClientsTable and RulesTable components to streamline the codebase. --- .../firewall/firewall-clients-grid.tsx | 215 ++++++++++++++ .../firewall/firewall-rules-grid.tsx | 142 ++++++++++ apps/web/src/routes/_auth/firewall.tsx | 267 ++++-------------- apps/web/tsconfig.tsbuildinfo | 2 +- 4 files changed, 419 insertions(+), 207 deletions(-) create mode 100644 apps/web/src/components/firewall/firewall-clients-grid.tsx create mode 100644 apps/web/src/components/firewall/firewall-rules-grid.tsx diff --git a/apps/web/src/components/firewall/firewall-clients-grid.tsx b/apps/web/src/components/firewall/firewall-clients-grid.tsx new file mode 100644 index 0000000..66c15e6 --- /dev/null +++ b/apps/web/src/components/firewall/firewall-clients-grid.tsx @@ -0,0 +1,215 @@ +import { + ColumnDef, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table' +import { useMemo } from 'react' + +import { Button } from '@evobgp/ui/components/button' + +import { ConfirmDialog } from '@/components/confirm-dialog' +import { StatusBadge } from '@/components/status-badge' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' +import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid' +import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' +import type { FirewallClient } from '@/types/api' + +function formatPacketCount(value?: number | null): string | null { + if (value == null || value <= 0) return null + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k` + return String(value) +} + +export interface FirewallClientsGridProps { + clients: FirewallClient[] + isLoading?: boolean + onApprove: (id: string) => void + onReject: (id: string) => void + approvePending?: boolean + rejectPending?: boolean + emptyTitle?: string +} + +export function FirewallClientsGrid({ + clients, + isLoading = false, + onApprove, + onReject, + approvePending = false, + rejectPending = false, + emptyTitle = 'Нет клиентов', +}: FirewallClientsGridProps) { + const columns = useMemo[]>( + () => [ + { + accessorKey: 'name', + header: ({ column }) => , + cell: ({ row }) => ( +
+
{row.original.name}
+
+ {row.original.hostname || row.original.token_prefix} +
+
+ ), + meta: { headerTitle: 'Имя' }, + }, + { + accessorKey: 'status', + header: ({ column }) => , + cell: ({ row }) => , + meta: { headerTitle: 'Статус' }, + }, + { + id: 'last_seen_at', + accessorFn: (row) => row.last_seen_at ?? '', + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.last_seen_at?.slice(0, 19) ?? '—'} + ), + sortingFn: (a, b) => { + const av = a.original.last_seen_at ?? '' + const bv = b.original.last_seen_at ?? '' + return av.localeCompare(bv) + }, + meta: { headerTitle: 'Last seen' }, + }, + { + id: 'apply', + enableSorting: false, + header: 'Apply', + cell: ({ row }) => { + const c = row.original + return ( + + {c.last_apply_status ?? '—'} + {c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''} + + ) + }, + meta: { headerTitle: 'Apply' }, + }, + { + id: 'packets', + enableSorting: false, + header: 'Пакеты', + cell: ({ row }) => { + const dropped = formatPacketCount(row.original.last_apply_packets_dropped) + const accepted = formatPacketCount(row.original.last_apply_packets_accepted) + if (!dropped && !accepted) { + return + } + return ( + + {dropped ? ↓{dropped} : null} + {dropped && accepted ? ' · ' : null} + {accepted ? ↑{accepted} : null} + + ) + }, + meta: { headerTitle: 'Пакеты' }, + }, + { + id: 'actions', + enableSorting: false, + header: () => null, + cell: ({ row }) => { + const c = row.original + return ( +
+ {c.status === 'pending' ? ( + <> + + + Отклонить + + } + title="Отклонить запрос?" + description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — запись будет удалена, токен перестанет работать.`} + confirmLabel="Отклонить" + destructive + onConfirm={() => onReject(c.id)} + /> + + ) : null} + {c.status === 'approved' ? ( + + Удалить + + } + title="Удалить клиент?" + description={`${c.name} — запись будет удалена, blocklist и токен перестанут работать.`} + confirmLabel="Удалить" + destructive + onConfirm={() => onReject(c.id)} + /> + ) : null} +
+ ) + }, + }, + ], + [approvePending, onApprove, onReject, rejectPending], + ) + + const table = useReactTable({ + data: clients, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getRowId: (row) => row.id, + initialState: { pagination: { pageSize: 10 } }, + }) + + return ( + + + + + + + ) +} diff --git a/apps/web/src/components/firewall/firewall-rules-grid.tsx b/apps/web/src/components/firewall/firewall-rules-grid.tsx new file mode 100644 index 0000000..20ce4f3 --- /dev/null +++ b/apps/web/src/components/firewall/firewall-rules-grid.tsx @@ -0,0 +1,142 @@ +import { + ColumnDef, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table' +import { useMemo } from 'react' + +import { Button } from '@evobgp/ui/components/button' + +import { ConfirmDialog } from '@/components/confirm-dialog' +import { StatusBadge } from '@/components/status-badge' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' +import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid' +import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' +import { communityLabel } from '@/lib/modules/helpers' +import type { BgpCommunity, FirewallRule } from '@/types/api' + +export interface FirewallRulesGridProps { + rules: FirewallRule[] + communities: BgpCommunity[] + isLoading?: boolean + onDelete: (id: string) => void + deletePending?: boolean + emptyTitle?: string +} + +export function FirewallRulesGrid({ + rules, + communities, + isLoading = false, + onDelete, + deletePending = false, + emptyTitle = 'Нет правил — blocklist пуст (default accept).', +}: FirewallRulesGridProps) { + const columns = useMemo[]>( + () => [ + { + accessorKey: 'priority', + header: ({ column }) => , + cell: ({ row }) => row.original.priority, + meta: { headerTitle: '#' }, + }, + { + accessorKey: 'action', + header: ({ column }) => , + cell: ({ row }) => ( + + ), + meta: { headerTitle: 'Действие' }, + }, + { + id: 'community', + enableSorting: false, + header: 'Community', + cell: ({ row }) => ( + + {row.original.community_id + ? communityLabel(row.original.community_id, communities) + : 'Все'} + + ), + meta: { headerTitle: 'Community' }, + }, + { + accessorKey: 'comment', + enableSorting: false, + header: 'Комментарий', + cell: ({ row }) => row.original.comment || '—', + meta: { headerTitle: 'Комментарий' }, + }, + { + id: 'actions', + enableSorting: false, + header: () => null, + cell: ({ row }) => { + const r = row.original + return ( + + Удалить + + } + title="Удалить правило?" + description={ + r.comment + ? `Правило #${r.priority} (${r.action}): ${r.comment}` + : `Правило #${r.priority} (${r.action}) будет удалено.` + } + confirmLabel="Удалить" + destructive + onConfirm={() => onDelete(r.id)} + /> + ) + }, + }, + ], + [communities, deletePending, onDelete], + ) + + const table = useReactTable({ + data: rules, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getRowId: (row) => row.id, + initialState: { pagination: { pageSize: 10 } }, + }) + + return ( + + + + + + + ) +} diff --git a/apps/web/src/routes/_auth/firewall.tsx b/apps/web/src/routes/_auth/firewall.tsx index c4bad53..773ca54 100644 --- a/apps/web/src/routes/_auth/firewall.tsx +++ b/apps/web/src/routes/_auth/firewall.tsx @@ -10,20 +10,13 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evob import { Input } from '@evobgp/ui/components/input' import { Label } from '@evobgp/ui/components/label' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@evobgp/ui/components/table' -import { ConfirmDialog } from '@/components/confirm-dialog' +import { FirewallClientsGrid } from '@/components/firewall/firewall-clients-grid' +import { FirewallRulesGrid } from '@/components/firewall/firewall-rules-grid' import { PageHeader } from '@/components/page-header' import { CommunitySelect } from '@/components/modules/community-select' -import { StatusBadge } from '@/components/status-badge' -import { communityLabel } from '@/lib/modules/helpers' +import { QueryState } from '@/components/query-state' +import { TableSkeleton } from '@/components/skeletons' import { directoriesCommunitiesQueryOptions } from '@/queries/directories' import { firewallClientsQueryOptions, @@ -34,7 +27,6 @@ import { useDeleteFirewallClient, useDeleteFirewallRule, } from '@/queries/firewall' -import type { BgpCommunity, FirewallClient } from '@/types/api' function httpsOrigin(origin: string): string { try { @@ -201,13 +193,25 @@ function FirewallPage() { - approve.mutate(id)} - onReject={(id) => deleteClient.mutate(id)} - approvePending={approve.isPending} - rejectPending={deleteClient.isPending} - /> + void clientsQ.refetch()} + skeleton={} + > + {() => ( + approve.mutate(id)} + onReject={(id) => deleteClient.mutate(id)} + approvePending={approve.isPending} + rejectPending={deleteClient.isPending} + /> + )} + @@ -257,198 +261,49 @@ function FirewallPage() { Добавить правило - deleteRule.mutate(id)} - /> + void rulesQ.refetch()} + skeleton={} + > + {() => ( + deleteRule.mutate(id)} + deletePending={deleteRule.isPending} + /> + )} + - approve.mutate(id)} - onReject={(id) => deleteClient.mutate(id)} - approvePending={approve.isPending} - rejectPending={deleteClient.isPending} - emptyTitle="Нет pending-запросов" - /> + void clientsQ.refetch()} + skeleton={} + > + {() => ( + approve.mutate(id)} + onReject={(id) => deleteClient.mutate(id)} + approvePending={approve.isPending} + rejectPending={deleteClient.isPending} + emptyTitle="Нет pending-запросов" + /> + )} + ) } - -function formatPacketCount(value?: number | null): string | null { - if (value == null || value <= 0) return null - if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` - if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k` - return String(value) -} - -function ClientsTable({ - clients, - onApprove, - onReject, - approvePending = false, - rejectPending = false, - emptyTitle = 'Нет клиентов', -}: { - clients: FirewallClient[] - onApprove: (id: string) => void - onReject: (id: string) => void - approvePending?: boolean - rejectPending?: boolean - emptyTitle?: string -}) { - if (clients.length === 0) { - return

{emptyTitle}

- } - return ( - - - - Имя - Статус - Last seen - Apply - Пакеты - - - - - {clients.map((c) => ( - - -
{c.name}
-
{c.hostname || c.token_prefix}
-
- - - - {c.last_seen_at?.slice(0, 19) ?? '—'} - - {c.last_apply_status ?? '—'} - {c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''} - - - {formatPacketCount(c.last_apply_packets_dropped) || formatPacketCount(c.last_apply_packets_accepted) ? ( - <> - {formatPacketCount(c.last_apply_packets_dropped) ? ( - ↓{formatPacketCount(c.last_apply_packets_dropped)} - ) : null} - {formatPacketCount(c.last_apply_packets_dropped) && formatPacketCount(c.last_apply_packets_accepted) - ? ' · ' - : null} - {formatPacketCount(c.last_apply_packets_accepted) ? ( - ↑{formatPacketCount(c.last_apply_packets_accepted)} - ) : null} - - ) : ( - '—' - )} - - -
- {c.status === 'pending' ? ( - <> - - - Отклонить - - } - title="Отклонить запрос?" - description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — запись будет удалена, токен перестанет работать.`} - confirmLabel="Отклонить" - destructive - onConfirm={() => onReject(c.id)} - /> - - ) : null} - {c.status === 'approved' ? ( - - Удалить - - } - title="Удалить клиент?" - description={`${c.name} — запись будет удалена, blocklist и токен перестанут работать.`} - confirmLabel="Удалить" - destructive - onConfirm={() => onReject(c.id)} - /> - ) : null} -
-
-
- ))} -
-
- ) -} - -function RulesTable({ - rules, - communities, - onDelete, -}: { - rules: { id: string; priority: number; action: string; community_id?: string | null; comment?: string }[] - communities: BgpCommunity[] - onDelete: (id: string) => void -}) { - if (rules.length === 0) { - return

Нет правил — blocklist пуст (default accept).

- } - return ( - - - - # - Действие - Community - Комментарий - - - - - {rules.map((r) => ( - - {r.priority} - - - - - {r.community_id ? communityLabel(r.community_id, communities) : 'Все'} - - {r.comment || '—'} - - - - - ))} - -
- ) -} diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo index 6c2185e..55323ab 100644 --- a/apps/web/tsconfig.tsbuildinfo +++ b/apps/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file