feat(firewall): refactor FirewallPage to use new grid components and improve loading states
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 43s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m13s
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 43s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m13s
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.
This commit is contained in:
@@ -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<ColumnDef<FirewallClient>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div className="font-medium">{row.original.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{row.original.hostname || row.original.token_prefix}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
{
|
||||
id: 'last_seen_at',
|
||||
accessorFn: (row) => row.last_seen_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Last seen" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">{row.original.last_seen_at?.slice(0, 19) ?? '—'}</span>
|
||||
),
|
||||
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 (
|
||||
<span className="text-xs">
|
||||
{c.last_apply_status ?? '—'}
|
||||
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
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 <span className="text-muted-foreground text-xs">—</span>
|
||||
}
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{dropped ? <span className="text-destructive">↓{dropped}</span> : null}
|
||||
{dropped && accepted ? ' · ' : null}
|
||||
{accepted ? <span className="text-success">↑{accepted}</span> : null}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Пакеты' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
{c.status === 'pending' ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
disabled={approvePending}
|
||||
onClick={() => onApprove(c.id)}
|
||||
>
|
||||
Одобрить
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
disabled={rejectPending}
|
||||
>
|
||||
Отклонить
|
||||
</Button>
|
||||
}
|
||||
title="Отклонить запрос?"
|
||||
description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — запись будет удалена, токен перестанет работать.`}
|
||||
confirmLabel="Отклонить"
|
||||
destructive
|
||||
onConfirm={() => onReject(c.id)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{c.status === 'approved' ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
disabled={rejectPending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
}
|
||||
title="Удалить клиент?"
|
||||
description={`${c.name} — запись будет удалена, blocklist и токен перестанут работать.`}
|
||||
confirmLabel="Удалить"
|
||||
destructive
|
||||
onConfirm={() => onReject(c.id)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[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 (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={clients.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyTitle}
|
||||
tableLayout={{ headerSticky: true, dense: true }}
|
||||
>
|
||||
<DataGridContainer>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
<DataGridPagination
|
||||
sizes={[10, 25, 50]}
|
||||
sizesLabel="Показать"
|
||||
sizesDescription="на странице"
|
||||
info="{from}–{to} из {count}"
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
previousPageLabel="Предыдущая страница"
|
||||
nextPageLabel="Следующая страница"
|
||||
/>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
@@ -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<ColumnDef<FirewallRule>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'priority',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="#" />,
|
||||
cell: ({ row }) => row.original.priority,
|
||||
meta: { headerTitle: '#' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Действие" />,
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge status={row.original.action} label={row.original.action} />
|
||||
),
|
||||
meta: { headerTitle: 'Действие' },
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
enableSorting: false,
|
||||
header: 'Community',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm">
|
||||
{row.original.community_id
|
||||
? communityLabel(row.original.community_id, communities)
|
||||
: 'Все'}
|
||||
</span>
|
||||
),
|
||||
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 (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
disabled={deletePending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
}
|
||||
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 (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={rules.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyTitle}
|
||||
tableLayout={{ headerSticky: true, dense: true }}
|
||||
>
|
||||
<DataGridContainer>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
<DataGridPagination
|
||||
sizes={[10, 25, 50]}
|
||||
sizesLabel="Показать"
|
||||
sizesDescription="на странице"
|
||||
info="{from}–{to} из {count}"
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
previousPageLabel="Предыдущая страница"
|
||||
nextPageLabel="Следующая страница"
|
||||
/>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="clients" className="mt-4">
|
||||
<ClientsTable
|
||||
clients={activeClients}
|
||||
onApprove={(id) => approve.mutate(id)}
|
||||
onReject={(id) => deleteClient.mutate(id)}
|
||||
approvePending={approve.isPending}
|
||||
rejectPending={deleteClient.isPending}
|
||||
/>
|
||||
<QueryState
|
||||
data={clientsQ.data}
|
||||
isLoading={clientsQ.isLoading}
|
||||
isError={clientsQ.isError}
|
||||
error={clientsQ.error}
|
||||
onRetry={() => void clientsQ.refetch()}
|
||||
skeleton={<TableSkeleton rows={5} cols={6} />}
|
||||
>
|
||||
{() => (
|
||||
<FirewallClientsGrid
|
||||
clients={activeClients}
|
||||
isLoading={clientsQ.isFetching && !clientsQ.isLoading}
|
||||
onApprove={(id) => approve.mutate(id)}
|
||||
onReject={(id) => deleteClient.mutate(id)}
|
||||
approvePending={approve.isPending}
|
||||
rejectPending={deleteClient.isPending}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rules" className="mt-4 space-y-4">
|
||||
@@ -257,198 +261,49 @@ function FirewallPage() {
|
||||
Добавить правило
|
||||
</Button>
|
||||
</div>
|
||||
<RulesTable
|
||||
rules={rules}
|
||||
communities={communities}
|
||||
onDelete={(id) => deleteRule.mutate(id)}
|
||||
/>
|
||||
<QueryState
|
||||
data={rulesQ.data}
|
||||
isLoading={rulesQ.isLoading}
|
||||
isError={rulesQ.isError}
|
||||
error={rulesQ.error}
|
||||
onRetry={() => void rulesQ.refetch()}
|
||||
skeleton={<TableSkeleton rows={5} cols={5} />}
|
||||
>
|
||||
{() => (
|
||||
<FirewallRulesGrid
|
||||
rules={rules}
|
||||
communities={communities}
|
||||
isLoading={rulesQ.isFetching && !rulesQ.isLoading}
|
||||
onDelete={(id) => deleteRule.mutate(id)}
|
||||
deletePending={deleteRule.isPending}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="requests" className="mt-4">
|
||||
<ClientsTable
|
||||
clients={pending}
|
||||
onApprove={(id) => approve.mutate(id)}
|
||||
onReject={(id) => deleteClient.mutate(id)}
|
||||
approvePending={approve.isPending}
|
||||
rejectPending={deleteClient.isPending}
|
||||
emptyTitle="Нет pending-запросов"
|
||||
/>
|
||||
<QueryState
|
||||
data={clientsQ.data}
|
||||
isLoading={clientsQ.isLoading}
|
||||
isError={clientsQ.isError}
|
||||
error={clientsQ.error}
|
||||
onRetry={() => void clientsQ.refetch()}
|
||||
skeleton={<TableSkeleton rows={3} cols={6} />}
|
||||
>
|
||||
{() => (
|
||||
<FirewallClientsGrid
|
||||
clients={pending}
|
||||
isLoading={clientsQ.isFetching && !clientsQ.isLoading}
|
||||
onApprove={(id) => approve.mutate(id)}
|
||||
onReject={(id) => deleteClient.mutate(id)}
|
||||
approvePending={approve.isPending}
|
||||
rejectPending={deleteClient.isPending}
|
||||
emptyTitle="Нет pending-запросов"
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 <p className="text-muted-foreground text-sm">{emptyTitle}</p>
|
||||
}
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Last seen</TableHead>
|
||||
<TableHead>Apply</TableHead>
|
||||
<TableHead>Пакеты</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{clients.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{c.name}</div>
|
||||
<div className="text-muted-foreground text-xs">{c.hostname || c.token_prefix}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={c.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{c.last_seen_at?.slice(0, 19) ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{c.last_apply_status ?? '—'}
|
||||
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">
|
||||
{formatPacketCount(c.last_apply_packets_dropped) || formatPacketCount(c.last_apply_packets_accepted) ? (
|
||||
<>
|
||||
{formatPacketCount(c.last_apply_packets_dropped) ? (
|
||||
<span className="text-destructive">↓{formatPacketCount(c.last_apply_packets_dropped)}</span>
|
||||
) : null}
|
||||
{formatPacketCount(c.last_apply_packets_dropped) && formatPacketCount(c.last_apply_packets_accepted)
|
||||
? ' · '
|
||||
: null}
|
||||
{formatPacketCount(c.last_apply_packets_accepted) ? (
|
||||
<span className="text-success">↑{formatPacketCount(c.last_apply_packets_accepted)}</span>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-2">
|
||||
{c.status === 'pending' ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={approvePending}
|
||||
onClick={() => onApprove(c.id)}
|
||||
>
|
||||
Одобрить
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
disabled={rejectPending}
|
||||
>
|
||||
Отклонить
|
||||
</Button>
|
||||
}
|
||||
title="Отклонить запрос?"
|
||||
description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — запись будет удалена, токен перестанет работать.`}
|
||||
confirmLabel="Отклонить"
|
||||
destructive
|
||||
onConfirm={() => onReject(c.id)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{c.status === 'approved' ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
disabled={rejectPending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
}
|
||||
title="Удалить клиент?"
|
||||
description={`${c.name} — запись будет удалена, blocklist и токен перестанут работать.`}
|
||||
confirmLabel="Удалить"
|
||||
destructive
|
||||
onConfirm={() => onReject(c.id)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
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 <p className="text-muted-foreground text-sm">Нет правил — blocklist пуст (default accept).</p>
|
||||
}
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>Действие</TableHead>
|
||||
<TableHead>Community</TableHead>
|
||||
<TableHead>Комментарий</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rules.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>{r.priority}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={r.action} label={r.action} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{r.community_id ? communityLabel(r.community_id, communities) : 'Все'}
|
||||
</TableCell>
|
||||
<TableCell>{r.comment || '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Button size="sm" variant="ghost" onClick={() => onDelete(r.id)}>
|
||||
Удалить
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user