feat(api, web): implement per-IP blocked stats for agents
- Added functionality to report per-IP drop counters in the `evofw-firewall.sh` script, capturing the top 200 IPs with packet counts. - Introduced new API endpoints to retrieve blocked IP statistics and reset these stats for agents, enhancing monitoring capabilities. - Updated the agent detail view to display blocked IPs, improving user visibility into agent performance. - Enhanced database schema and repositories to support the storage and management of IP block statistics. These changes provide a comprehensive view of blocked IPs, improving the overall management and monitoring of agents.
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from '@tanstack/react-table'
|
||||
import { BanIcon, RouterIcon } 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 { agentBlockedIpsQueryOptions } from '@/queries'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
|
||||
/**
|
||||
* Per-IP/CIDR drop counters from Linux nft/ipset.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* · https://reui.io/preview/base/empty-state-12
|
||||
* MikroTik: address-list has no per-entry counters — empty hint only.
|
||||
*/
|
||||
|
||||
export type BlockedIpRow = {
|
||||
ip: string
|
||||
packets: number
|
||||
first_seen_at: string
|
||||
last_seen_at: string
|
||||
}
|
||||
|
||||
type AgentBlockedIpsProps = {
|
||||
agentId: string
|
||||
platform: string
|
||||
}
|
||||
|
||||
const packetFmt = new Intl.NumberFormat('ru-RU')
|
||||
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
|
||||
function formatSeen(iso: string): string {
|
||||
const t = Date.parse(iso)
|
||||
if (Number.isNaN(t)) return '—'
|
||||
return seenFmt.format(t)
|
||||
}
|
||||
|
||||
export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||
const isMikrotik = platform === 'mikrotik'
|
||||
const q = useQuery({
|
||||
...agentBlockedIpsQueryOptions(agentId),
|
||||
enabled: !isMikrotik,
|
||||
})
|
||||
|
||||
const columns = useMemo<ColumnDef<BlockedIpRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'ip',
|
||||
id: 'ip',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="IP / CIDR" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs tabular-nums">{row.original.ip}</span>
|
||||
),
|
||||
meta: { headerTitle: 'IP / CIDR' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'packets',
|
||||
id: 'packets',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Packets" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">
|
||||
{packetFmt.format(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">
|
||||
{formatSeen(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.ip,
|
||||
})
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Blocked IPs</FrameTitle>
|
||||
<FrameDescription>
|
||||
Drop-пакеты по записям deny (nft/ipset). Top по накопленным packets.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
{isMikrotik ? (
|
||||
<EmptyState
|
||||
icon={RouterIcon}
|
||||
title="Per-IP недоступен на MikroTik"
|
||||
description="У address-list в RouterOS нет counters по записи. Доступны только суммарные Traffic ↓/↑ с filter-правил."
|
||||
centered={false}
|
||||
className="py-8"
|
||||
/>
|
||||
) : 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={BanIcon}
|
||||
title="Не удалось загрузить"
|
||||
description={q.error?.message ?? 'Ошибка API'}
|
||||
centered={false}
|
||||
className="py-8"
|
||||
/>
|
||||
) : data.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={BanIcon}
|
||||
title="Пока нет hit’ов"
|
||||
description="Когда deny-префиксы начнут дропать пакеты, здесь появятся IP/CIDR с counters."
|
||||
centered={false}
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
tableLayout={{ dense: true }}
|
||||
>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-s
|
||||
import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
|
||||
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
|
||||
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
|
||||
import { AgentBlockedIps } from '@/components/agents/agent-blocked-ips'
|
||||
import {
|
||||
AgentCloneSetsSheet,
|
||||
AgentOverrideSheet,
|
||||
@@ -102,6 +103,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ips'] })
|
||||
void qc.invalidateQueries({ queryKey: ['stats'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
@@ -317,6 +319,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
preview={previewQ.data}
|
||||
isLoading={previewQ.isLoading}
|
||||
/>
|
||||
|
||||
<AgentBlockedIps agentId={agentId} platform={a.platform} />
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
|
||||
Reference in New Issue
Block a user