- lib/format.ts: ru-RU форматтеры дат/чисел в одном месте — убраны дубли из 8 компонентов (agent-card, fleet-grid, blocked-ips/ports, facts, lifecycle, host-firewall, lists-columns, system-monitor) - lib/nav.ts: единый конфиг маршрутов — sidebar, ⌘K-поиск и breadcrumbs рендерятся из одного источника (было 3 расходящихся копии) - lib/permissions.ts + useCan: клиентский RBAC — кнопки создания/подтверждения скрываются без fw:*:write/admin (сервер остаётся авторитетным) - удалён мёртвый код ~2500 строк: stepper, data-grid dnd/virtual/visibility варианты, settings-shell
156 lines
4.5 KiB
TypeScript
156 lines
4.5 KiB
TypeScript
import { useMemo } from 'react'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
import {
|
||
getCoreRowModel,
|
||
useReactTable,
|
||
type ColumnDef,
|
||
} from '@tanstack/react-table'
|
||
import { NetworkIcon } from 'lucide-react'
|
||
import {
|
||
Frame,
|
||
FrameDescription,
|
||
FrameHeader,
|
||
FramePanel,
|
||
FrameTitle,
|
||
} from '@/components/reui/frame'
|
||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||
import { EmptyState } from '@/components/empty-state'
|
||
import { agentBlockedPortsQueryOptions } from '@/queries'
|
||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||
import { formatNumber, formatStampDateTime } from '@/lib/format'
|
||
|
||
/**
|
||
* Aggregate destination ports hit by denied sources (Linux nft).
|
||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||
* · https://reui.io/preview/base/empty-state-12
|
||
*/
|
||
|
||
export type BlockedPortRow = {
|
||
port: number
|
||
protocol: string
|
||
packets: number
|
||
last_seen_at: string
|
||
}
|
||
|
||
type AgentBlockedPortsProps = {
|
||
agentId: string
|
||
}
|
||
|
||
|
||
export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
|
||
const q = useQuery(agentBlockedPortsQueryOptions(agentId))
|
||
|
||
const columns = useMemo<ColumnDef<BlockedPortRow>[]>(
|
||
() => [
|
||
{
|
||
accessorKey: 'port',
|
||
id: 'port',
|
||
header: ({ column }) => (
|
||
<DataGridColumnHeader column={column} title="Port" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<span className="font-mono text-xs tabular-nums">
|
||
{row.original.port}
|
||
</span>
|
||
),
|
||
meta: { headerTitle: 'Port' },
|
||
},
|
||
{
|
||
accessorKey: 'protocol',
|
||
id: 'protocol',
|
||
header: ({ column }) => (
|
||
<DataGridColumnHeader column={column} title="Proto" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<span className="font-mono text-xs uppercase">
|
||
{row.original.protocol}
|
||
</span>
|
||
),
|
||
meta: { headerTitle: 'Proto' },
|
||
},
|
||
{
|
||
accessorKey: 'packets',
|
||
id: 'packets',
|
||
header: ({ column }) => (
|
||
<DataGridColumnHeader column={column} title="Packets" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<span className="tabular-nums">
|
||
{formatNumber(row.original.packets)}
|
||
</span>
|
||
),
|
||
meta: { headerTitle: 'Packets' },
|
||
},
|
||
{
|
||
accessorKey: 'last_seen_at',
|
||
id: 'last_seen_at',
|
||
header: ({ column }) => (
|
||
<DataGridColumnHeader column={column} title="Last seen" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<span className="text-muted-foreground text-xs tabular-nums">
|
||
{formatStampDateTime(row.original.last_seen_at)}
|
||
</span>
|
||
),
|
||
meta: { headerTitle: 'Last seen' },
|
||
},
|
||
],
|
||
[],
|
||
)
|
||
|
||
const data = q.data?.items ?? []
|
||
const table = useReactTable({
|
||
data,
|
||
columns,
|
||
getCoreRowModel: getCoreRowModel(),
|
||
getRowId: (r) => `${r.protocol}/${r.port}`,
|
||
})
|
||
|
||
return (
|
||
<Frame dense spacing="sm">
|
||
<FrameHeader>
|
||
<FrameTitle>Top ports</FrameTitle>
|
||
<FrameDescription>
|
||
Destination ports (tcp/udp), в которые слали запросы blocked IP. nft
|
||
dynamic set deny_port_hits.
|
||
</FrameDescription>
|
||
</FrameHeader>
|
||
<FramePanel className="p-0">
|
||
{q.isLoading ? (
|
||
<div className="flex flex-col gap-2 p-4">
|
||
<Skeleton className="h-8 w-full" />
|
||
<Skeleton className="h-8 w-full" />
|
||
<Skeleton className="h-8 w-2/3" />
|
||
</div>
|
||
) : q.isError ? (
|
||
<EmptyState
|
||
icon={NetworkIcon}
|
||
title="Не удалось загрузить"
|
||
description={q.error?.message ?? 'Ошибка API'}
|
||
centered={false}
|
||
className="py-8"
|
||
/>
|
||
) : data.length === 0 ? (
|
||
<EmptyState
|
||
icon={NetworkIcon}
|
||
title="Пока нет port hit’ов"
|
||
description="Нужен nft + deny_port_hits. После drop с deny появятся tcp/udp dport. Re-run install-ссылки обновляет правила."
|
||
centered={false}
|
||
className="py-8"
|
||
/>
|
||
) : (
|
||
<DataGrid
|
||
table={table}
|
||
recordCount={data.length}
|
||
tableLayout={{ dense: true }}
|
||
>
|
||
<DataGridTable />
|
||
</DataGrid>
|
||
)}
|
||
</FramePanel>
|
||
</Frame>
|
||
)
|
||
}
|