feat(firewall): nftables hit-counter pro Regel + Live-Hits-Spalte in der UI

- ruleset.nft.tpl: operator-rules bekommen `counter <action> comment "egid:<id>"`
  → nft zählt Packets + Bytes per Regel ab dem letzten Ruleset-Apply
- handlers/firewall_counters.go: GET /firewall/counters parst
  `sudo nft list table inet edgeguard` per Regex, liefert [{rule_id,packets,bytes}]
- handlers/firewall_counters_test.go: unit-tests für parseNFTCounters
- Firewall/Rules.tsx: neue "Hits"-Spalte (Packet-Count, Tooltip mit Bytes),
  10s-Polling via TanStack Query ['fw','counters']
- i18n de+en: fw.rule.hits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-21 08:42:31 +02:00
parent 9a05e8bca8
commit f4d178cb38
8 changed files with 176 additions and 3 deletions

View File

@@ -101,6 +101,7 @@
"object": "Adress-Objekt", "group": "Adress-Gruppe",
"serviceKind": "Service-Typ", "serviceGroup": "Service-Gruppe",
"comment": "Kommentar",
"hits": "Hits",
"add": "Regel hinzufügen", "edit": "Regel bearbeiten",
"deleteConfirm": "Diese Regel wirklich löschen?",
"emptyTitle": "Noch keine eigenen Firewall-Regeln.",

View File

@@ -101,6 +101,7 @@
"object": "Address object", "group": "Address group",
"serviceKind": "Service kind", "serviceGroup": "Service group",
"comment": "Comment",
"hits": "Hits",
"add": "Add rule", "edit": "Edit rule",
"deleteConfirm": "Really delete this rule?",
"emptyTitle": "No custom firewall rules yet.",

View File

@@ -1,10 +1,12 @@
import { useState } from 'react'
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Tag, message } from 'antd'
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Tag, Tooltip, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { FireOutlined } from '@ant-design/icons'
const { Text } = Typography
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
@@ -40,6 +42,23 @@ const ACTION_COLORS: Record<FwRule['action'], string> = {
reject: 'orange',
}
interface RuleCounter { rule_id: number; packets: number; bytes: number }
async function listCounters(): Promise<RuleCounter[]> {
try {
const r = await apiClient.get('/firewall/counters')
if (!isEnvelope(r.data)) return []
return (r.data.data as { counters?: RuleCounter[] }).counters ?? []
} catch { return [] }
}
function fmtBytes(n: number): string {
if (n >= 1_073_741_824) return (n / 1_073_741_824).toFixed(1) + ' GB'
if (n >= 1_048_576) return (n / 1_048_576).toFixed(1) + ' MB'
if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB'
return n + ' B'
}
async function listRules(): Promise<FwRule[]> {
const r = await apiClient.get('/firewall/rules')
if (!isEnvelope(r.data)) return []
@@ -99,6 +118,12 @@ export default function RulesTab() {
const { data: svs } = useQuery({ queryKey: ['fw', 'svc'], queryFn: listSv })
const { data: sgs } = useQuery({ queryKey: ['fw', 'svc-grp'], queryFn: listSG })
const { data: zones } = useQuery({ queryKey: ['fw', 'zones'], queryFn: listZones })
const { data: counters } = useQuery({
queryKey: ['fw', 'counters'],
queryFn: listCounters,
refetchInterval: 10_000,
})
const counterByID = new Map((counters ?? []).map(c => [c.rule_id, c]))
// Picker options: 'any' (special) + every zone the operator has
// declared. Fallback to the seed list while the query is loading.
@@ -186,6 +211,18 @@ export default function RulesTab() {
},
{ title: t('fw.rule.enabled'), dataIndex: 'enabled', key: 'enabled', render: (v: boolean) => v ? '✓' : '—' },
{ title: t('fw.rule.name'), dataIndex: 'name', key: 'name', render: (v?: string) => v ?? '—' },
{
title: t('fw.rule.hits'), key: 'hits', width: 90,
render: (_, r) => {
const c = counterByID.get(r.id)
if (!c) return <Text type="secondary" style={{ fontSize: 11 }}></Text>
return (
<Tooltip title={`${c.packets.toLocaleString()} pkts · ${fmtBytes(c.bytes)}`}>
<Text style={{ fontSize: 11 }}>{c.packets.toLocaleString()}</Text>
</Tooltip>
)
},
},
{
title: t('common.edit'), key: 'actions',
render: (_, row) => (