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