feat(api, web): implement per-IP blocked stats for agents
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m51s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- 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:
Denozordec
2026-08-07 14:15:10 +07:00
parent 9fd7ddb26c
commit 4ee78032c4
14 changed files with 743 additions and 15 deletions
@@ -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>